diff --git a/teachertool/src/App.tsx b/teachertool/src/App.tsx index e32570963827..76fc205c7f82 100644 --- a/teachertool/src/App.tsx +++ b/teachertool/src/App.tsx @@ -14,6 +14,7 @@ import { loadCatalogAsync } from "./transforms/loadCatalogAsync"; import { loadValidatorPlansAsync } from "./transforms/loadValidatorPlansAsync"; import { tryLoadLastActiveRubricAsync } from "./transforms/tryLoadLastActiveRubricAsync"; import { ImportRubricModal } from "./components/ImportRubricModal"; +import { ConfirmationModal } from "./components/ConfirmationModal"; export const App = () => { const { state, dispatch } = useContext(AppStateContext); @@ -57,6 +58,7 @@ export const App = () => { + > ); diff --git a/teachertool/src/components/ConfirmationModal.tsx b/teachertool/src/components/ConfirmationModal.tsx new file mode 100644 index 000000000000..982c32452bfa --- /dev/null +++ b/teachertool/src/components/ConfirmationModal.tsx @@ -0,0 +1,38 @@ +import { useContext, useEffect, useState } from "react"; +import { AppStateContext } from "../state/appStateContext"; +import { Modal } from "react-common/components/controls/Modal"; +import { hideModal } from "../transforms/hideModal"; + +export interface IProps {} +export const ConfirmationModal: React.FC = () => { + const { state: teacherTool } = useContext(AppStateContext); + + function handleCancel() { + hideModal(); + teacherTool.confirmationOptions?.onCancel?.(); + } + + function handleContinue() { + hideModal(); + teacherTool.confirmationOptions?.onContinue?.(); + } + + const actions = [ + { + label: lf("Cancel"), + className: "secondary", + onClick: handleCancel, + }, + { + label: lf("Continue"), + className: "primary", + onClick: handleContinue, + }, + ]; + + return teacherTool.modal === "confirmation" && teacherTool.confirmationOptions ? ( + + {teacherTool.confirmationOptions.message} + + ) : null; +}; diff --git a/teachertool/src/components/DragAndDropFileSurface.tsx b/teachertool/src/components/DragAndDropFileSurface.tsx new file mode 100644 index 000000000000..a1060fd1a484 --- /dev/null +++ b/teachertool/src/components/DragAndDropFileSurface.tsx @@ -0,0 +1,108 @@ +import { classList } from "react-common/components/util"; +import { Strings } from "../constants"; +import { NoticeLabel } from "./NoticeLabel"; +import { useRef, useState } from "react"; +import css from "./styling/DragAndDropFileSurface.module.scss"; +import { Button } from "react-common/components/controls/Button"; + +export interface DragAndDropFileSurfaceProps { + onFileDroppedAsync: (file: File) => void; + errorMessage?: string; +} +export const DragAndDropFileSurface: React.FC = ({ onFileDroppedAsync, errorMessage }) => { + const [fileIsOverSurface, setFileIsOverSurface] = useState(false); + const [errorKey, setErrorKey] = useState(0); + const fileInputRef = useRef(null); + + function handleDragOver(event: React.DragEvent) { + // Stop the browser from intercepting the file. + event.stopPropagation(); + event.preventDefault(); + } + + function handleDragEnter(event: React.DragEvent) { + event.stopPropagation(); + event.preventDefault(); + setFileIsOverSurface(true); + } + + function handleDragLeave(event: React.DragEvent) { + event.stopPropagation(); + event.preventDefault(); + setFileIsOverSurface(false); + } + + function handleDrop(event: React.DragEvent) { + event.stopPropagation(); + event.preventDefault(); + + setFileIsOverSurface(false); + + const file = event.dataTransfer.files[0]; + if (file) { + processNewFile(file); + } + } + + function handleFileFromBrowse(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (file) { + processNewFile(file); + } + } + + function processNewFile(file: File) { + // Change errorKey so that the error component resets (notably, resetting animations). + setErrorKey(errorKey + 1); + onFileDroppedAsync(file); + } + + /* + We can't use the drag-and-drop-file-surface directly to handle most drop events, because the child elements interfere with them. + To solve this, we add a transparent div (droppable-surface) over everything and use that for most drag-related event handling. + However, we don't want the transparent droppable-surface to intercept pointer events when there is no drag occurring, so + we still use the drag-and-drop-file-surface to detect dragEnter events and only intercept pointer events on the droppable-surface + after that has happened. + */ + return ( + + + + {fileIsOverSurface ? Strings.ReleaseToUpload : Strings.DragAndDrop} + + {lf("or")} + + {/* The button triggers a hidden file input to open the file browser */} + fileInputRef?.current?.click()} + > + {Strings.Browse} + + + + + + {errorMessage && ( + + {errorMessage} + + )} + + + + ); +}; diff --git a/teachertool/src/components/ImportRubricModal.tsx b/teachertool/src/components/ImportRubricModal.tsx index b5bb25e52ff6..17bb85d332aa 100644 --- a/teachertool/src/components/ImportRubricModal.tsx +++ b/teachertool/src/components/ImportRubricModal.tsx @@ -1,97 +1,39 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useState } from "react"; import { AppStateContext } from "../state/appStateContext"; import { Modal } from "react-common/components/controls/Modal"; import { hideModal } from "../transforms/hideModal"; -import css from "./styling/ImportRubricModal.module.scss"; import { getRubricFromFileAsync } from "../transforms/getRubricFromFileAsync"; -import { NoticeLabel } from "./NoticeLabel"; -import { Rubric } from "../types/rubric"; -import { RubricPreview } from "./RubricPreview"; -import { setRubric } from "../transforms/setRubric"; +import { DragAndDropFileSurface } from "./DragAndDropFileSurface"; +import { Strings } from "../constants"; +import css from "./styling/ImportRubricModal.module.scss"; +import { replaceActiveRubricAsync } from "../transforms/replaceActiveRubricAsync"; export interface IProps {} export const ImportRubricModal: React.FC = () => { const { state: teacherTool } = useContext(AppStateContext); - const [selectedFile, setSelectedFile] = useState(undefined); - const [selectedRubric, setSelectedRubric] = useState(undefined); const [errorMessage, setErrorMessage] = useState(undefined); - useEffect(() => { - async function updatePreview(file: File) { - const parsedRubric = await getRubricFromFileAsync(file, false /* allow partial */); - if (!parsedRubric) { - setErrorMessage(lf("Invalid rubric file.")); - } else { - setErrorMessage(undefined); - } - setSelectedRubric(parsedRubric); - } - - if (selectedFile) { - updatePreview(selectedFile); - } else { - setSelectedRubric(undefined); - setErrorMessage(undefined); - } - }, [selectedFile]); - function closeModal() { - setSelectedFile(undefined); setErrorMessage(undefined); - setSelectedRubric(undefined); hideModal(); } - function handleFileChange(event: React.ChangeEvent) { - if (event.target.files && event.target.files.length > 0) { - setSelectedFile(event.target.files[0]); + async function handleFileDroppedAsync(file: File) { + const parsedRubric = await getRubricFromFileAsync(file, false /* allow partial */); + if (!parsedRubric) { + setErrorMessage(Strings.InvalidRubricFile); } else { - setSelectedFile(undefined); - } - } - - function handleImportClicked() { - if (selectedRubric) { - setRubric(selectedRubric); + setErrorMessage(undefined); + closeModal(); + replaceActiveRubricAsync(parsedRubric); } - - closeModal(); } - const actions = [ - { - label: lf("Cancel"), - className: "secondary", - onClick: closeModal, - }, - { - label: lf("Import"), - className: "primary", - onClick: handleImportClicked, - disabled: !selectedRubric, - }, - ]; - return teacherTool.modal === "import-rubric" ? ( - + - - {lf("Warning! Your current rubric will be overwritten by the imported rubric.")} - - {errorMessage && {errorMessage}} - {selectedRubric && ( - - - - )} - + ) : null; diff --git a/teachertool/src/components/styling/DragAndDropFileSurface.module.scss b/teachertool/src/components/styling/DragAndDropFileSurface.module.scss new file mode 100644 index 000000000000..098fc4af00e5 --- /dev/null +++ b/teachertool/src/components/styling/DragAndDropFileSurface.module.scss @@ -0,0 +1,73 @@ +.drag-and-drop-file-surface { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + + background-color: var(--pxt-content-background); + border-radius: 0.5rem; + border: 1px dashed var(--pxt-content-secondary-foreground); + + padding: 2rem; + min-height: 30vh; + + .instruction-container { + display: flex; + flex-direction: column; + align-items: center; + + color: var(--pxt-content-secondary-foreground); + font-size: 2rem; + font-weight: 600; + + .upload-icon { + font-size: 3rem; + } + + .or-browse-container { + display: flex; + flex-direction: row; + gap: 0.5rem; + + .or-container { + font-weight: 400; + } + + .browse-button { + font-weight: 600; + font-size: 2rem; + } + } + } + + @keyframes fadeInAndOut { + 0% { opacity: 0; } + 5% { opacity: 1; } + 50% { opacity: 1; } + 100% { opacity: 0; } + } + + .error-label-container { + position: absolute; + bottom: 1rem; + animation: fadeInAndOut 4s forwards; + + i { + display: none; + } + } + + .droppable-surface { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + pointer-events: none; + &.dragging { + pointer-events: all; + } + } +} diff --git a/teachertool/src/components/styling/ImportRubricModal.module.scss b/teachertool/src/components/styling/ImportRubricModal.module.scss index 0fe511c6c64c..fc483639da56 100644 --- a/teachertool/src/components/styling/ImportRubricModal.module.scss +++ b/teachertool/src/components/styling/ImportRubricModal.module.scss @@ -1,13 +1,19 @@ -.import-rubric { - display: flex; - flex-direction: column; - gap: 0.5rem; +.import-rubric-modal { + div[class*="common-modal-body"] { + background-color: var(--pxt-content-background); + } + + .import-rubric { + display: flex; + flex-direction: column; + gap: 0.5rem; - .rubric-preview-container { - max-height: 50vh; - overflow-y: auto; - border: 2px solid var(--pxt-content-foreground); - border-radius: 0.3rem; - background-color: var(--pxt-content-background-glass); + .rubric-preview-container { + max-height: 50vh; + overflow-y: auto; + border: 2px solid var(--pxt-content-foreground); + border-radius: 0.3rem; + background-color: var(--pxt-content-background-glass); + } } -} \ No newline at end of file +} diff --git a/teachertool/src/constants.ts b/teachertool/src/constants.ts index 51b0d7cc69e8..f13c0df82390 100644 --- a/teachertool/src/constants.ts +++ b/teachertool/src/constants.ts @@ -14,6 +14,11 @@ export namespace Strings { export const Actions = lf("Actions"); export const AutoRun = lf("auto-run"); export const AutoRunDescription = lf("Automatically re-evaluate when the rubric or project changes"); + export const DragAndDrop = lf("Drag & Drop"); + export const ReleaseToUpload = lf("Release to Upload"); + export const Browse = lf("Browse"); + export const SelectRubricFile = lf("Select Rubric File"); + export const InvalidRubricFile = lf("Invalid Rubric File"); } export namespace Ticks { diff --git a/teachertool/src/state/actions.ts b/teachertool/src/state/actions.ts index 135d1f75d83f..423bd54fa618 100644 --- a/teachertool/src/state/actions.ts +++ b/teachertool/src/state/actions.ts @@ -1,4 +1,4 @@ -import { ModalType, ToastWithId, TabName } from "../types"; +import { ModalType, ToastWithId, TabName, ConfirmationModalOptions } from "../types"; import { CatalogCriteria, CriteriaEvaluationResult } from "../types/criteria"; import { Rubric } from "../types/rubric"; @@ -55,6 +55,11 @@ type SetRubric = ActionBase & { rubric: Rubric; }; +type SetConfirmationOptions = ActionBase & { + type: "SET_CONFIRMATION_OPTIONS"; + options: ConfirmationModalOptions | undefined; +}; + type ShowModal = ActionBase & { type: "SHOW_MODAL"; modal: ModalType; @@ -93,6 +98,7 @@ export type Action = | SetTargetConfig | SetCatalog | SetRubric + | SetConfirmationOptions | ShowModal | HideModal | SetValidatorPlans @@ -147,6 +153,11 @@ const setRubric = (rubric: Rubric): SetRubric => ({ rubric, }); +const setConfirmationOptions = (options: ConfirmationModalOptions | undefined): SetConfirmationOptions => ({ + type: "SET_CONFIRMATION_OPTIONS", + options, +}); + const showModal = (modal: ModalType): ShowModal => ({ type: "SHOW_MODAL", modal, @@ -181,6 +192,7 @@ export { setTargetConfig, setCatalog, setRubric, + setConfirmationOptions, showModal, hideModal, setValidatorPlans, diff --git a/teachertool/src/state/helpers.ts b/teachertool/src/state/helpers.ts index c836e6b2d2ce..4f8e78b00aa9 100644 --- a/teachertool/src/state/helpers.ts +++ b/teachertool/src/state/helpers.ts @@ -30,6 +30,10 @@ export function verifyRubricIntegrity(rubric: Rubric): { validCriteria: CriteriaInstance[]; invalidCriteria: CriteriaInstance[]; } { + if (!rubric || !rubric.criteria) { + return { valid: false, validCriteria: [], invalidCriteria: [] }; + } + const validCriteria: CriteriaInstance[] = []; const invalidCriteria: CriteriaInstance[] = []; for (const criteria of rubric.criteria) { diff --git a/teachertool/src/state/reducer.ts b/teachertool/src/state/reducer.ts index 74b84f6d7caf..43696e408fcf 100644 --- a/teachertool/src/state/reducer.ts +++ b/teachertool/src/state/reducer.ts @@ -67,6 +67,12 @@ export default function reducer(state: AppState, action: Action): AppState { rubric: action.rubric, }; } + case "SET_CONFIRMATION_OPTIONS": { + return { + ...state, + confirmationOptions: action.options, + }; + } case "SHOW_MODAL": { return { ...state, diff --git a/teachertool/src/state/state.ts b/teachertool/src/state/state.ts index 6b0b607fc636..31b350d0f58b 100644 --- a/teachertool/src/state/state.ts +++ b/teachertool/src/state/state.ts @@ -1,4 +1,4 @@ -import { ModalType, ToastWithId, TabName } from "../types"; +import { ModalType, ToastWithId, TabName, ConfirmationModalOptions } from "../types"; import { CatalogCriteria, CriteriaEvaluationResult, CriteriaInstance } from "../types/criteria"; import { Rubric } from "../types/rubric"; import { makeRubric } from "../utils"; @@ -14,6 +14,7 @@ export type AppState = { activeTab: TabName; validatorPlans: pxt.blocks.ValidatorPlan[] | undefined; autorun: boolean; + confirmationOptions: ConfirmationModalOptions | undefined; flags: { testCatalog: boolean; }; @@ -29,6 +30,7 @@ export const initialAppState: AppState = { activeTab: "home", validatorPlans: undefined, autorun: false, + confirmationOptions: undefined, flags: { testCatalog: false, }, diff --git a/teachertool/src/transforms/confirmAsync.ts b/teachertool/src/transforms/confirmAsync.ts index f7793623bc2a..aa9314998a1a 100644 --- a/teachertool/src/transforms/confirmAsync.ts +++ b/teachertool/src/transforms/confirmAsync.ts @@ -1,7 +1,18 @@ -export async function confirmAsync(prompt: string) { - // TODO: Replace with our own confirmation dialog. - if (!confirm(prompt)) { - return false; - } - return true; +import { stateAndDispatch } from "../state"; +import { showModal } from "./showModal"; +import * as Actions from "../state/actions"; + +export async function confirmAsync(title: string, message: string): Promise { + const { dispatch } = stateAndDispatch(); + return new Promise(resolve => { + dispatch( + Actions.setConfirmationOptions({ + title, + message, + onCancel: () => resolve(false), + onContinue: () => resolve(true), + }) + ); + showModal("confirmation"); + }); } diff --git a/teachertool/src/transforms/loadRubricAsync.ts b/teachertool/src/transforms/loadRubricAsync.ts index f48c6ccc0003..2380c9adf3bd 100644 --- a/teachertool/src/transforms/loadRubricAsync.ts +++ b/teachertool/src/transforms/loadRubricAsync.ts @@ -3,9 +3,7 @@ import { fetchJsonDocAsync } from "../services/backendRequests"; import { verifyRubricIntegrity } from "../state/helpers"; import { Rubric } from "../types/rubric"; import { makeToast } from "../utils"; -import { confirmAsync } from "./confirmAsync"; -import { setActiveTab } from "./setActiveTab"; -import { setRubric } from "./setRubric"; +import { replaceActiveRubricAsync } from "./replaceActiveRubricAsync"; import { showToast } from "./showToast"; export async function loadRubricAsync(rubricUrl: string) { @@ -23,10 +21,5 @@ export async function loadRubricAsync(rubricUrl: string) { return; } - if (!(await confirmAsync(Strings.ConfirmReplaceRubricMsg))) { - return; - } - - setRubric(json); - setActiveTab("rubric"); + await replaceActiveRubricAsync(json); } diff --git a/teachertool/src/transforms/replaceActiveRubricAsync.ts b/teachertool/src/transforms/replaceActiveRubricAsync.ts new file mode 100644 index 000000000000..216af0781708 --- /dev/null +++ b/teachertool/src/transforms/replaceActiveRubricAsync.ts @@ -0,0 +1,23 @@ +import { Strings } from "../constants"; +import { stateAndDispatch } from "../state"; +import { isRubricLoaded } from "../state/helpers"; +import { Rubric } from "../types/rubric"; +import { confirmAsync } from "./confirmAsync"; +import { setActiveTab } from "./setActiveTab"; +import { setRubric } from "./setRubric"; + +export async function replaceActiveRubricAsync(newRubric: Rubric): Promise { + const { state: teacherTool } = stateAndDispatch(); + + const title = + !newRubric.name && !newRubric.criteria?.length + ? lf("Create Empty Rubric") + : lf("Import '{0}'?", newRubric.name ? newRubric.name : Strings.UntitledRubric); + if (isRubricLoaded(teacherTool) && !(await confirmAsync(title, Strings.ConfirmReplaceRubricMsg))) { + return false; + } + + setRubric(newRubric); + setActiveTab("rubric"); + return true; +} diff --git a/teachertool/src/transforms/resetRubricAsync.ts b/teachertool/src/transforms/resetRubricAsync.ts index 216ff3943aa8..3bf786caa5a7 100644 --- a/teachertool/src/transforms/resetRubricAsync.ts +++ b/teachertool/src/transforms/resetRubricAsync.ts @@ -1,22 +1,12 @@ import { stateAndDispatch } from "../state"; -import { isRubricLoaded } from "../state/helpers"; import * as Actions from "../state/actions"; -import { setRubric } from "./setRubric"; -import { confirmAsync } from "./confirmAsync"; import { makeRubric } from "../utils"; -import { Strings } from "../constants"; +import { replaceActiveRubricAsync } from "./replaceActiveRubricAsync"; export async function resetRubricAsync() { - const { state: teachertool, dispatch } = stateAndDispatch(); + const { dispatch } = stateAndDispatch(); - if (isRubricLoaded(teachertool)) { - if (!(await confirmAsync(Strings.ConfirmReplaceRubricMsg))) { - return; - } + if (await replaceActiveRubricAsync(makeRubric())) { + dispatch(Actions.clearAllEvalResults()); } - - dispatch(Actions.clearAllEvalResults()); - setRubric(makeRubric()); - - dispatch(Actions.setActiveTab("rubric")); } diff --git a/teachertool/src/types/index.ts b/teachertool/src/types/index.ts index d5a904aaf231..68f15cdc24e0 100644 --- a/teachertool/src/types/index.ts +++ b/teachertool/src/types/index.ts @@ -21,7 +21,7 @@ export type ToastWithId = Toast & { id: string; }; -export type ModalType = "catalog-display" | "import-rubric"; +export type ModalType = "catalog-display" | "import-rubric" | "confirmation"; export type TabName = "home" | "rubric" | "results"; @@ -44,3 +44,10 @@ export type CarouselCardSet = { }; export type RequestStatus = "init" | "loading" | "error" | "success"; + +export type ConfirmationModalOptions = { + title: string; + message: string; + onCancel: () => void; + onContinue: () => void; +}; diff --git a/theme/themepacks.less b/theme/themepacks.less index 955d9fe36a50..d717b91ed6b6 100644 --- a/theme/themepacks.less +++ b/theme/themepacks.less @@ -25,6 +25,7 @@ --pxt-content-background-glass: #C7D2FE40; --pxt-content-foreground: #1E293B; --pxt-content-accent: #EEF2FF; + --pxt-content-secondary-foreground: #666666; /// Primary button --pxt-button-primary-background: #065F46; --pxt-button-primary-background-glass: #065F4640;