From a3442592304671f435d87e7f0916c71805dee7cb Mon Sep 17 00:00:00 2001 From: Thomas Sparks <69657545+thsparks@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:06:22 -0700 Subject: [PATCH] Teacher Tool: Block Picker (#9936) This change adds a "Block Picker" which can be used for customizable block parameters. Doing this involved: 1. Making it so the iframe always loads something, even when no sharelink has been provided. We need this so we can call into it to get the blocks. It's invisible when there is no share link, but it's still loaded. 2. Adding commands we can send to the iframe to get all the toolbox categories & blocks, then to get images of blocks (as xml, when we have it, or simply by id) 3. Adding a new modal for the block picker, which displays categories and blocks to choose from 4. Some adjustments to the LazyImage to make the loading look a little smoother (I think) --- gulpfile.js | 1 + localtypings/pxteditor.d.ts | 42 ++++- pxteditor/editorcontroller.ts | 29 +++- pxtlib/util.ts | 8 + pxtservices/iframeDriver.ts | 38 ++++- .../components/controls/LazyImage.tsx | 8 +- react-common/styles/controls/LazyImage.less | 10 +- teachertool/src/App.tsx | 2 + .../src/components/AddCriteriaButton.tsx | 3 +- .../src/components/BlockPickerModal.tsx | 159 ++++++++++++++++++ teachertool/src/components/CatalogModal.tsx | 7 +- .../src/components/ConfirmationModal.tsx | 27 ++- .../components/CriteriaInstanceDisplay.tsx | 60 ++++++- .../src/components/DebouncedTextarea.tsx | 2 +- teachertool/src/components/HomeScreen.tsx | 3 +- .../src/components/ImportRubricModal.tsx | 2 +- teachertool/src/components/MakecodeFrame.tsx | 21 +-- .../src/components/RubricWorkspace.tsx | 3 +- .../styling/BlockPickerModal.module.scss | 99 +++++++++++ .../CriteriaInstanceDisplay.module.scss | 14 ++ .../styling/MakeCodeFrame.module.scss | 6 + teachertool/src/constants.ts | 5 + .../src/services/makecodeEditorService.ts | 24 ++- teachertool/src/state/actions.ts | 49 ++++-- teachertool/src/state/reducer.ts | 24 ++- teachertool/src/state/state.ts | 13 +- teachertool/src/transforms/confirmAsync.ts | 20 +-- teachertool/src/transforms/hideModal.ts | 2 +- .../src/transforms/loadBlockImagesAsync.ts | 34 ++++ .../transforms/loadToolboxCategoriesAsync.ts | 36 ++++ teachertool/src/transforms/showModal.ts | 4 +- teachertool/src/types/errorCode.ts | 1 + teachertool/src/types/index.ts | 11 -- teachertool/src/types/modalOptions.ts | 23 +++ teachertool/src/utils/index.ts | 8 + webapp/src/app.tsx | 56 ++++++ 36 files changed, 752 insertions(+), 102 deletions(-) create mode 100644 teachertool/src/components/BlockPickerModal.tsx create mode 100644 teachertool/src/components/styling/BlockPickerModal.module.scss create mode 100644 teachertool/src/transforms/loadBlockImagesAsync.ts create mode 100644 teachertool/src/transforms/loadToolboxCategoriesAsync.ts create mode 100644 teachertool/src/types/modalOptions.ts diff --git a/gulpfile.js b/gulpfile.js index 80558301dc0b..7d96ef8361cd 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -777,6 +777,7 @@ exports.tt = teacherTool; exports.icons = buildSVGIcons; exports.testhelpers = testhelpers; exports.testpxteditor = testpxteditor; +exports.reactCommon = reactCommon; exports.cli = gulp.series( gulp.parallel(pxtlib, pxtweb), gulp.parallel(pxtcompiler, pxtsim, backendutils), diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index ebba04fdd713..5e72b5bf2b89 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -54,6 +54,8 @@ declare namespace pxt.editor { | "redo" | "renderblocks" | "renderpython" + | "renderxml" + | "renderbyblockid" | "setscale" | "startactivity" | "saveproject" @@ -64,6 +66,7 @@ declare namespace pxt.editor { | "requestprojectcloudstatus" | "convertcloudprojectstolocal" | "setlanguagerestriction" + | "gettoolboxcategories" | "toggletrace" // EditorMessageToggleTraceRequest | "togglehighcontrast" @@ -294,6 +297,21 @@ declare namespace pxt.editor { layout?: BlockLayout; } + export interface EditorMessageRenderXmlRequest extends EditorMessageRequest { + action: "renderxml"; + // xml to render + xml: string; + snippetMode?: boolean; + layout?: BlockLayout; + } + + export interface EditorMessageRenderByBlockIdRequest extends EditorMessageRequest { + action: "renderbyblockid"; + blockId: string; + snippetMode?: boolean; + layout?: BlockLayout; + } + export interface EditorMessageRunEvalRequest extends EditorMessageRequest { action: "runeval"; validatorPlan: pxt.blocks.ValidatorPlan; @@ -305,6 +323,16 @@ declare namespace pxt.editor { xml: Promise; } + export interface EditorMessageRenderXmlResponse { + svg: SVGSVGElement; + resultXml: Promise; + } + + export interface EditorMessageRenderByBlockIdResponse { + svg: SVGSVGElement; + resultXml: Promise; + } + export interface EditorMessageRenderPythonRequest extends EditorMessageRequest { action: "renderpython"; // typescript code to render @@ -398,6 +426,15 @@ declare namespace pxt.editor { restriction: pxt.editor.LanguageRestriction; } + export interface EditorMessageGetToolboxCategoriesRequest extends EditorMessageRequest { + action: "gettoolboxcategories"; + advanced?: boolean; + } + + export interface EditorMessageGetToolboxCategoriesResponse { + categories: pxt.editor.ToolboxCategoryDefinition[]; + } + export interface DataStreams { console?: T; messages?: T; @@ -927,10 +964,13 @@ declare namespace pxt.editor { blocksScreenshotAsync(pixelDensity?: number, encodeBlocks?: boolean): Promise; renderBlocksAsync(req: pxt.editor.EditorMessageRenderBlocksRequest): Promise; renderPythonAsync(req: pxt.editor.EditorMessageRenderPythonRequest): Promise; + renderXml(req: pxt.editor.EditorMessageRenderXmlRequest): pxt.editor.EditorMessageRenderXmlResponse; + renderByBlockIdAsync(req: pxt.editor.EditorMessageRenderByBlockIdRequest): Promise; // FIXME (riknoll) need to figure out how to type this better // getBlocks(): Blockly.Block[]; getBlocks(): any[]; + getToolboxCategories(advanced?: boolean): pxt.editor.EditorMessageGetToolboxCategoriesResponse; toggleHighContrast(): void; setHighContrast(on: boolean): void; @@ -1218,4 +1258,4 @@ declare namespace pxt.workspace { fireEvent?: (ev: pxt.editor.EditorEvent) => void; } -} \ No newline at end of file +} diff --git a/pxteditor/editorcontroller.ts b/pxteditor/editorcontroller.ts index 019e1c27f232..964415ea16b3 100644 --- a/pxteditor/editorcontroller.ts +++ b/pxteditor/editorcontroller.ts @@ -154,6 +154,26 @@ export function bindEditorMessages(getEditorAsync: () => Promise) }) }); } + case "renderxml": { + const rendermsg = data as pxt.editor.EditorMessageRenderXmlRequest; + return Promise.resolve() + .then(() => { + const r = projectView.renderXml(rendermsg); + return r.resultXml.then((svg: any) => { + resp = svg.xml; + }) + }); + } + case "renderbyblockid": { + const rendermsg = data as pxt.editor.EditorMessageRenderByBlockIdRequest; + return Promise.resolve() + .then(() => projectView.renderByBlockIdAsync(rendermsg)) + .then(r => { + return r.resultXml.then((svg: any) => { + resp = svg.xml; + }) + }); + } case "runeval": { const evalmsg = data as pxt.editor.EditorMessageRunEvalRequest; const plan = evalmsg.validatorPlan; @@ -166,6 +186,13 @@ export function bindEditorMessages(getEditorAsync: () => Promise) resp = { result: results }; }); } + case "gettoolboxcategories": { + const msg = data as pxt.editor.EditorMessageGetToolboxCategoriesRequest; + return Promise.resolve() + .then(() => { + resp = projectView.getToolboxCategories(msg.advanced); + }); + } case "renderpython": { const rendermsg = data as pxt.editor.EditorMessageRenderPythonRequest; return Promise.resolve() @@ -346,4 +373,4 @@ export function postHostMessageAsync(msg: pxt.editor.EditorMessageRequest): Prom if (!msg.response) resolve(undefined) }) -} \ No newline at end of file +} diff --git a/pxtlib/util.ts b/pxtlib/util.ts index f7e6d84a151b..543ace5b8ddd 100644 --- a/pxtlib/util.ts +++ b/pxtlib/util.ts @@ -1426,6 +1426,14 @@ namespace ts.pxtc.Util { return (n || "").split(/(?=[A-Z])/g).join(" ").toLowerCase(); } + export function camelCaseToLowercaseWithSpaces(n: string) { + return n.replace(/([A-Z])/gm, ' $1').toLocaleLowerCase().trim(); + } + + export function snakeCaseToLowercaseWithSpaces(n: string) { + return n.replace(/_/g, ' ').toLocaleLowerCase().trim(); + } + export function range(len: number) { let r: number[] = [] for (let i = 0; i < len; ++i) r.push(i) diff --git a/pxtservices/iframeDriver.ts b/pxtservices/iframeDriver.ts index 46f8447c3b69..cd906f0ae11f 100644 --- a/pxtservices/iframeDriver.ts +++ b/pxtservices/iframeDriver.ts @@ -352,6 +352,42 @@ export class IframeDriver { return (resp.resp as pxt.editor.EditorMessageRenderPythonResponse).python; } + async renderXml(xml: string) { + const resp = await this.sendRequest( + { + type: "pxteditor", + action: "renderxml", + xml + } as pxt.editor.EditorMessageRenderXmlRequest + ) as pxt.editor.EditorMessageResponse; + + return resp.resp; + } + + async renderByBlockId(blockId: string) { + const resp = await this.sendRequest( + { + type: "pxteditor", + action: "renderbyblockid", + blockId: blockId + } as pxt.editor.EditorMessageRenderByBlockIdRequest + ) as pxt.editor.EditorMessageResponse; + + return resp.resp; + } + + async getToolboxCategories(advanced?: boolean): Promise { + const resp = await this.sendRequest( + { + type: "pxteditor", + action: "gettoolboxcategories", + advanced + } as pxt.editor.EditorMessageGetToolboxCategoriesRequest + ) as pxt.editor.EditorMessageResponse; + + return (resp.resp as pxt.editor.EditorMessageGetToolboxCategoriesResponse).categories; + } + async runValidatorPlan(validatorPlan: pxt.blocks.ValidatorPlan, planLib: pxt.blocks.ValidatorPlan[]) { const resp = await this.sendRequest( { @@ -550,4 +586,4 @@ export class IframeDriver { } } } -} \ No newline at end of file +} diff --git a/react-common/components/controls/LazyImage.tsx b/react-common/components/controls/LazyImage.tsx index 1bb98e1e4da0..017b5ced9222 100644 --- a/react-common/components/controls/LazyImage.tsx +++ b/react-common/components/controls/LazyImage.tsx @@ -5,6 +5,7 @@ export interface LazyImageProps extends ControlProps { src: string; alt: string; title?: string; + loadingElement?: JSX.Element; } let observer: IntersectionObserver; @@ -20,6 +21,7 @@ export const LazyImage = (props: LazyImageProps) => { ariaLabel, ariaHidden, ariaDescribedBy, + loadingElement, } = props; initObserver(); @@ -34,9 +36,8 @@ export const LazyImage = (props: LazyImageProps) => { observer.observe(ref); } - - return
+
{loadingElement ? loadingElement :
}
{ aria-hidden={ariaHidden} aria-describedby={ariaDescribedBy} /> -
} @@ -85,4 +85,4 @@ function initObserver() { }) } observer = new IntersectionObserver(onIntersection, config); -} \ No newline at end of file +} diff --git a/react-common/styles/controls/LazyImage.less b/react-common/styles/controls/LazyImage.less index 4b41568d55ca..bcc3db8f2057 100644 --- a/react-common/styles/controls/LazyImage.less +++ b/react-common/styles/controls/LazyImage.less @@ -4,10 +4,12 @@ align-items: center; .common-spinner { - position: absolute; width: 60px; height: 60px; + } + .loading-element { + position: absolute; opacity: 1; transition: opacity 0.3s ease; } @@ -28,7 +30,7 @@ } .common-lazy-image-wrapper.loaded { - .common-spinner { + .loading-element { opacity: 0; } @@ -42,7 +44,7 @@ } .common-lazy-image-wrapper.error { - .common-spinner { + .loading-element { opacity: 0; } @@ -53,4 +55,4 @@ img { opacity: 0; } -} \ No newline at end of file +} diff --git a/teachertool/src/App.tsx b/teachertool/src/App.tsx index 76fc205c7f82..4973715e2cf8 100644 --- a/teachertool/src/App.tsx +++ b/teachertool/src/App.tsx @@ -15,6 +15,7 @@ import { loadValidatorPlansAsync } from "./transforms/loadValidatorPlansAsync"; import { tryLoadLastActiveRubricAsync } from "./transforms/tryLoadLastActiveRubricAsync"; import { ImportRubricModal } from "./components/ImportRubricModal"; import { ConfirmationModal } from "./components/ConfirmationModal"; +import { BlockPickerModal } from "./components/BlockPickerModal"; export const App = () => { const { state, dispatch } = useContext(AppStateContext); @@ -59,6 +60,7 @@ export const App = () => { + ); diff --git a/teachertool/src/components/AddCriteriaButton.tsx b/teachertool/src/components/AddCriteriaButton.tsx index 5e51815e3676..8e3935c20876 100644 --- a/teachertool/src/components/AddCriteriaButton.tsx +++ b/teachertool/src/components/AddCriteriaButton.tsx @@ -5,6 +5,7 @@ import { AppStateContext } from "../state/appStateContext"; import { useContext, useMemo } from "react"; import { classList } from "react-common/components/util"; import { Strings } from "../constants"; +import { CatalogDisplayOptions } from "../types/modalOptions"; interface IProps {} @@ -19,7 +20,7 @@ export const AddCriteriaButton: React.FC = ({}) => {
+
+ ) : null; +}; + +const LoadingBlocks: React.FC = () => { + return ( +
+
+
+ ); +}; + +export interface BlockPickerModalProps {} +export const BlockPickerModal: React.FC = ({}) => { + const { state: teacherTool } = useContext(AppStateContext); + const [blockPickerOptions, setBlockPickerOptions] = useState(undefined); + + useEffect(() => { + if (teacherTool.modalOptions?.modal === "block-picker") { + setBlockPickerOptions(teacherTool.modalOptions as BlockPickerOptions); + } else { + setBlockPickerOptions(undefined); + } + }, [teacherTool.modalOptions]); + + function handleBlockSelected(block: pxt.editor.ToolboxBlockDefinition) { + if (blockPickerOptions) { + setParameterValue(blockPickerOptions.criteriaInstanceId, blockPickerOptions.paramName, block.blockId); + } else { + logError(ErrorCode.selectedBlockWithoutOptions, "Block selected without block picker options."); + } + + hideModal(); + } + + const modalActions = [ + { + label: Strings.Cancel, + className: "secondary", + onClick: hideModal, + }, + ]; + + return blockPickerOptions ? ( + + {teacherTool.toolboxCategories && + Object.values(teacherTool.toolboxCategories).map(category => { + return ( + handleBlockSelected(block)} + /> + ); + })} + {!teacherTool.toolboxCategories && } + + ) : null; +}; diff --git a/teachertool/src/components/CatalogModal.tsx b/teachertool/src/components/CatalogModal.tsx index 10503b0f4249..c755d1e5f8eb 100644 --- a/teachertool/src/components/CatalogModal.tsx +++ b/teachertool/src/components/CatalogModal.tsx @@ -7,6 +7,7 @@ import { addCriteriaToRubric } from "../transforms/addCriteriaToRubric"; import { CatalogCriteria } from "../types/criteria"; import { getSelectableCatalogCriteria } from "../state/helpers"; import { ReadOnlyCriteriaDisplay } from "./ReadonlyCriteriaDisplay"; +import { Strings } from "../constants"; import css from "./styling/CatalogModal.module.scss"; interface CatalogModalProps {} @@ -47,18 +48,18 @@ export const CatalogModal: React.FC = ({}) => { const modalActions = [ { - label: lf("Cancel"), + label: Strings.Cancel, className: "secondary", onClick: closeModal, }, { - label: lf("Add Selected"), + label: Strings.AddSelected, className: "primary", onClick: handleAddSelectedClicked, }, ]; - return teacherTool.modal === "catalog-display" ? ( + return teacherTool.modalOptions?.modal === "catalog-display" ? ( = () => { const { state: teacherTool } = useContext(AppStateContext); + const [confirmationModalOptions, setConfirmationModalOptions] = useState( + undefined + ); + + useEffect(() => { + if (teacherTool.modalOptions?.modal === "confirmation") { + setConfirmationModalOptions(teacherTool.modalOptions as ConfirmationModalOptions); + } else { + setConfirmationModalOptions(undefined); + } + }, [teacherTool.modalOptions]); function handleCancel() { hideModal(); - teacherTool.confirmationOptions?.onCancel?.(); + confirmationModalOptions?.onCancel?.(); } function handleContinue() { hideModal(); - teacherTool.confirmationOptions?.onContinue?.(); + confirmationModalOptions?.onContinue?.(); } const actions = [ { - label: lf("Cancel"), + label: Strings.Cancel, className: "secondary", onClick: handleCancel, }, { - label: lf("Continue"), + label: Strings.Continue, className: "primary", onClick: handleContinue, }, ]; - return teacherTool.modal === "confirmation" && teacherTool.confirmationOptions ? ( - - {teacherTool.confirmationOptions.message} + return confirmationModalOptions ? ( + + {confirmationModalOptions.message} ) : null; }; diff --git a/teachertool/src/components/CriteriaInstanceDisplay.tsx b/teachertool/src/components/CriteriaInstanceDisplay.tsx index ae0b7c7873ac..b26da1a253af 100644 --- a/teachertool/src/components/CriteriaInstanceDisplay.tsx +++ b/teachertool/src/components/CriteriaInstanceDisplay.tsx @@ -3,11 +3,16 @@ import { CriteriaInstance, CriteriaParameterValue } from "../types/criteria"; import { logDebug } from "../services/loggingService"; import { setParameterValue } from "../transforms/setParameterValue"; import { classList } from "react-common/components/util"; -import { splitCriteriaTemplate } from "../utils"; +import { getReadableBlockString, splitCriteriaTemplate } from "../utils"; // eslint-disable-next-line import/no-internal-modules import css from "./styling/CriteriaInstanceDisplay.module.scss"; -import { useState } from "react"; +import { useContext, useMemo, useState } from "react"; import { Input } from "react-common/components/controls/Input"; +import { Button } from "react-common/components/controls/Button"; +import { AppStateContext } from "../state/appStateContext"; +import { Strings } from "../constants"; +import { showModal } from "../transforms/showModal"; +import { BlockPickerOptions } from "../types/modalOptions"; interface InlineInputSegmentProps { initialValue: string; @@ -30,7 +35,7 @@ const InlineInputSegment: React.FC = ({ setParameterValue(instance.instanceId, param.name, newValue); } - const tooltip = isEmpty ? lf("{0}: value required", param.name) : param.name; + const tooltip = isEmpty ? `"${param.name}: ${Strings.ValueRequired}` : param.name; return (
= ({ ); }; +interface BlockInputSegmentProps { + instance: CriteriaInstance; + param: CriteriaParameterValue; +} +interface BlockData { + category: pxt.editor.ToolboxCategoryDefinition; + block: pxt.editor.ToolboxBlockDefinition; +} +const BlockInputSegment: React.FC = ({ instance, param }) => { + const { state: teacherTool } = useContext(AppStateContext); + function handleClick() { + showModal({ + modal: "block-picker", + criteriaInstanceId: instance.instanceId, + paramName: param.name, + } as BlockPickerOptions); + } + + const blockData = useMemo(() => { + if (!param.value || !teacherTool.toolboxCategories) { + return undefined; + } + + // Scan all categories and find the block with the matching id + for (const category of Object.values(teacherTool.toolboxCategories)) { + const block = category.blocks?.find(b => b.blockId === param.value); + if (block) { + return { category, block }; + } + } + return undefined; + }, [param.value, teacherTool.toolboxCategories]); + + const style = blockData ? { backgroundColor: blockData.category.color, color: "white" } : undefined; + return ( +