Skip to content

Commit

Permalink
feat: Save experiment data in interactive state [PT-187809685]
Browse files Browse the repository at this point in the history
This allows for the experiment to be viewed in the class dashboard.  The data is primarily stored in Firebase but the class dashboard does not grant Firebase JWT tokens.

This also updates the lara runtime component to disable inputs when viewing in report mode.
  • Loading branch information
dougmartin committed Jun 30, 2024
1 parent 18edb64 commit 3d15b03
Show file tree
Hide file tree
Showing 6 changed files with 55 additions and 34 deletions.
5 changes: 3 additions & 2 deletions src/lara-app/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface IProps {
export const AppComponent:React.FC<IProps> = () => {
const [error, setError] = useState<any>();
const {
connectedToLara, initMessage, experiment, previewMode, firebaseJWT, runKey, setAuthoredState, setHeight, setDataset, log
connectedToLara, initMessage, experiment, previewMode, firebaseJWT, runKey, setAuthoredState, setHeight, log, saveExperimentData, interactiveState
} = useInteractiveApi({setError});

const renderMessage = (message: string) => <div className={css.message}>{message}</div>;
Expand Down Expand Up @@ -62,7 +62,8 @@ export const AppComponent:React.FC<IProps> = () => {
experiment={experiment}
runKey={runKey}
firebaseJWT={firebaseJWT}
setDataset={setDataset}
saveExperimentData={saveExperimentData}
interactiveState={interactiveState}
setError={setError}
defaultSectionIndex={defaultSectionIndex}
reportMode={initMessage.mode === "report"}
Expand Down
30 changes: 19 additions & 11 deletions src/lara-app/components/runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@ import * as firebase from "firebase/app";
import "firebase/firestore";

import { Experiment } from "../../shared/components/experiment";
import { IExperiment, IExperimentData, IExperimentConfig } from "../../shared/experiment-types";
import { IExperiment, IExperimentData, IExperimentConfig, IExperimentV1 } from "../../shared/experiment-types";
import { IRun } from "../../mobile-app/hooks/use-runs";
import { createCodeForExperimentRun, getSaveExperimentRunUrl } from "../../shared/api";
import { CODE_LENGTH } from "../../mobile-app/components/uploader";
import { getURLParam } from "../../shared/utils/get-url-param";
import ResizeObserver from "resize-observer-polyfill";
import { IDataset } from "@concord-consortium/lara-interactive-api";
const QRCode = require("qrcode-svg");
import { generateDataset } from "../utils/generate-dataset";
import css from "./runtime.module.scss";
import { IJwtClaims } from "@concord-consortium/lara-plugin-api";
import { downloadCSV } from "../utils/download-csv";
import { IInteractiveStateJSON } from "../hooks/interactive-api";

const QRCode = require("qrcode-svg");

import css from "./runtime.module.scss";

const UPDATE_QR_INTERVAL = 1000 * 60 * 60; // 60 minutes

Expand All @@ -30,7 +31,8 @@ export type IQRCodeContent = IQRCodeContentV1 | IQRCodeContentV11;

interface IProps {
experiment: IExperiment;
setDataset: (dataset: IDataset | null) => void;
saveExperimentData: (newData: IExperimentData, experiment: IExperimentV1) => void;
interactiveState: IInteractiveStateJSON | null;
runKey?: string;
firebaseJWT?: IJwtClaims;
setError: (error: any) => void;
Expand All @@ -42,7 +44,7 @@ interface IProps {
}

export const RuntimeComponent = ({
experiment, runKey, firebaseJWT, setError, defaultSectionIndex, reportMode, previewMode, setHeight, setDataset, log
experiment, runKey, firebaseJWT, setError, defaultSectionIndex, reportMode, previewMode, setHeight, log, saveExperimentData, interactiveState
} : IProps) => {
const [experimentData, setExperimentData] = useState<IExperimentData|undefined>();
const [queriedFirestore, setQueriedFirestore] = useState(false);
Expand Down Expand Up @@ -105,11 +107,12 @@ export const RuntimeComponent = ({
const newData = experimentRef.current?.data as IExperimentData | undefined;
setExperimentData(newData);
if (newData) {
// This will generate dataset and send it to parent window using LARA Interactive API. Dataset is used
// by the graph interactive. Note that the mobile app import saves data directly in Firestore, so this
// This will generate dataset and send it to parent window using LARA Interactive API along with the
// full interactive state so that it can be viewed in the class dashboard.
// Dataset is used by the graph interactive. Note that the mobile app import saves data directly in Firestore, so this
// listener is the only place where we have a chance to catch this data update, generate dataset,
// and finally send it to ActivityPlayer.
setDataset(generateDataset(newData, experiment));
saveExperimentData(newData, experiment);
}

// if there is no data force an upload - DISABLED FOR NOW
Expand All @@ -118,6 +121,10 @@ export const RuntimeComponent = ({
setError(err);
});
}

if (previewMode && interactiveState && interactiveState.data) {
setExperimentData(interactiveState.data);
}
}, [runKey, previewMode]);

// re-generate QR code if showing
Expand Down Expand Up @@ -170,7 +177,7 @@ export const RuntimeComponent = ({
if (reportOrPreviewMode || !runKey || !firebaseJWT) {
// Dataset is usually generated in the Firestore #onSnapshot handler, but the preview mode doesn't use Firestore.
// Generate it manually, so authors can see the connection between Vortex and the graph interactive.
setDataset(generateDataset(data, experiment));
saveExperimentData(data, experiment);
return;
}

Expand Down Expand Up @@ -237,6 +244,7 @@ export const RuntimeComponent = ({
defaultSectionIndex={defaultSectionIndex}
onDataChange={handleSaveData}
log={log}
reportMode={reportMode}
/>
</div>
{showQrContainer &&
Expand Down
24 changes: 14 additions & 10 deletions src/lara-app/hooks/interactive-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
import * as firebase from "firebase/app";
import * as jwt from "jsonwebtoken";
import { v4 as uuidv4 } from "uuid";
import { IExperiment, IExperimentV1 } from "../../shared/experiment-types";
import { IExperiment, IExperimentV1, IExperimentData } from "../../shared/experiment-types";
import { Experiments } from "../../mobile-app/hooks/use-experiments";
import { IAuthoredState } from "../../authoring-app/components/lara-authoring";
import { setHeight, IDataset, IInitInteractive, IInteractiveStateWithDataset, useAuthoredState, useInteractiveState, useInitMessage, setSupportedFeatures, getFirebaseJwt, log } from "@concord-consortium/lara-interactive-api";
Expand All @@ -13,9 +13,10 @@ const experiments = require("../../data/experiments.json") as Experiments;

export type IInitInteractiveData = IInitInteractive<IInteractiveStateJSON, IAuthoredState>;

interface IInteractiveStateJSON extends IInteractiveStateWithDataset {
export interface IInteractiveStateJSON extends IInteractiveStateWithDataset {
runKey: string | undefined;
experimentId: string | undefined;
data?: IExperimentData;
}

const findExperiment = (experimentId?: string) => {
Expand All @@ -30,6 +31,7 @@ export const useInteractiveApi = (options: {setError: (error: any) => void}) =>
// previewMode is disabled by default. It'll be turned on when firebaseJWT cannot be obtained.
const [previewMode, setPreviewMode] = useState<boolean>(false);
const dataset = useRef<IDataset | null>(null);
const data = useRef<IExperimentData| undefined>(undefined);

// use ref for runKey and experimentId values as they are used in iframe phone callback
// and the current state value is not available in that closure
Expand All @@ -38,12 +40,11 @@ export const useInteractiveApi = (options: {setError: (error: any) => void}) =>

const initMessage = useInitMessage<IInteractiveStateJSON, IAuthoredState>();
const { setAuthoredState } = useAuthoredState<IAuthoredState>();
const { setInteractiveState } = useInteractiveState<IInteractiveStateJSON>();
const { interactiveState, setInteractiveState } = useInteractiveState<IInteractiveStateJSON>();

useEffect(() => {
if (initMessage) {
const { mode } = initMessage;
const interactiveState: IInteractiveStateJSON | null = ((mode === "runtime") || (mode === "report")) ? (initMessage as any).interactiveState : null; // as any due to TypeScript 3 - remove after upgrade
let _experiment: IExperimentV1 | undefined;

setConnectedToLara(true);
Expand Down Expand Up @@ -81,6 +82,7 @@ export const useInteractiveApi = (options: {setError: (error: any) => void}) =>
if (_experiment) {
dataset.current = interactiveState?.dataset || generateDataset({ experimentData: [] }, _experiment);
}
data.current = interactiveState?.data;
if (!existingRunKey) {
// Once runKey, experimentId and dataset are set for the **first time**, make sure they're saved back
// in LARA or ActivityPlayer. This is especially important in ActivityPlayer which is not polling
Expand Down Expand Up @@ -143,20 +145,22 @@ export const useInteractiveApi = (options: {setError: (error: any) => void}) =>
}, [initMessage]);

const sendCurrentInteractiveState = () => {
const intState: IInteractiveStateJSON = {
const initState: IInteractiveStateJSON = {
runKey: runKey.current,
experimentId: experimentId.current,
dataset: dataset.current
dataset: dataset.current,
data: data.current,
};
setInteractiveState(intState);
setInteractiveState(initState);
};

const setDataset = (newDataset: IDataset | null) => {
dataset.current = newDataset;
const saveExperimentData = (theData: IExperimentData, theExperiment: IExperimentV1) => {
data.current = theData;
dataset.current = generateDataset(theData, theExperiment);
sendCurrentInteractiveState();
};

return {
connectedToLara, initMessage, experiment, previewMode, firebaseJWT, runKey: runKey.current, setAuthoredState, setHeight, setDataset, log
connectedToLara, initMessage, experiment, previewMode, firebaseJWT, runKey: runKey.current, setAuthoredState, setHeight, log, saveExperimentData, interactiveState
};
};
5 changes: 3 additions & 2 deletions src/shared/components/experiment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ interface IProps {
inputDisabled?: boolean;
setInputDisabled?: React.Dispatch<React.SetStateAction<boolean>>;
log?: (action: string, data?: object | undefined) => void;
reportMode?: boolean;
}

export const Experiment: React.FC<IProps> = ({ experiment, data, onDataChange, config, defaultSectionIndex, inputDisabled, setInputDisabled, log }) => {
export const Experiment: React.FC<IProps> = ({ experiment, data, onDataChange, config, defaultSectionIndex, inputDisabled, setInputDisabled, log, reportMode }) => {
const { schema } = experiment;
const { sections } = schema;
const [section, setSection] = useState<ISection>(sections[defaultSectionIndex || 0]);
Expand Down Expand Up @@ -65,7 +66,7 @@ export const Experiment: React.FC<IProps> = ({ experiment, data, onDataChange, c
experiment={experiment}
experimentConfig={config}
formData={currentData}
inputDisabled={inputDisabled}
inputDisabled={inputDisabled || reportMode}
setInputDisabled={setInputDisabled}
onDataChange={onExperimentDataChange}
log={log}
Expand Down
24 changes: 15 additions & 9 deletions src/shared/components/photo-or-note-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,13 @@ interface IPhotoProps {
saveAll: () => void;
width: number;
height: number;
inputDisabled?: boolean;
}

export const Photo: React.FC<IPhotoProps> = ({photo, deletePhoto, saveAll, width, height}) => {
export const Photo: React.FC<IPhotoProps> = ({photo, deletePhoto, saveAll, width, height, inputDisabled}) => {
const {localPhotoUrl, remotePhotoUrl} = photo;
const addCaptionRef = useRef<HTMLInputElement|null>(null);
const handleDeletePhoto = () => deletePhoto(photo);
const handleDeletePhoto = () => !inputDisabled && deletePhoto(photo);
const handleAddCaptionKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (addCaptionRef.current) {
photo.note = addCaptionRef.current.value.trim();
Expand Down Expand Up @@ -139,16 +140,16 @@ export const Thumbnail: React.FC<{photo: IPhotoOrNote, selected: boolean, select
);
};

export const Note: React.FC<{note: IPhotoOrNote, deleteNote: (note: IPhotoOrNote) => void}> = ({note, deleteNote}) => {
export const Note: React.FC<{note: IPhotoOrNote, deleteNote: (note: IPhotoOrNote) => void, inputDisabled?: boolean}> = ({note, deleteNote, inputDisabled}) => {
const localTime = (new Date(note.timestamp)).toLocaleString();
const handleDeleteNote = () => deleteNote(note);
const handleDeleteNote = () => !inputDisabled && deleteNote(note);
return (
<div className={css.note}>
<div className={css.noteMenu}>
{!inputDisabled && <div className={css.noteMenu}>
<MenuComponent icon={"delete"}>
<MenuItemComponent icon={"delete"} onClick={handleDeleteNote}>Delete Note</MenuItemComponent>
</MenuComponent>
</div>
</div>}
<div className={css.noteText}>{note.note}</div>
<div className={css.noteTimestamp}>{localTime}</div>
</div>
Expand Down Expand Up @@ -184,6 +185,7 @@ export const PhotoOrNoteField: React.FC<FieldProps> = props => {
const showCameraButton = !!formContext.experimentConfig?.showCameraButton;
const minCameraWidth = formContext.experimentConfig?.minCameraWidth || 0;
const minCameraHeight = formContext.experimentConfig?.minCameraHeight || 0;
const inputDisabled = formContext.inputDisabled;

const updateFormData = (newFormData: IPhotoOrNote[]) => {
setFormData(newFormData);
Expand Down Expand Up @@ -222,7 +224,7 @@ export const PhotoOrNoteField: React.FC<FieldProps> = props => {
const handleSelectNoteSubTab = () => setSubTab("note");
const handleSelectPhotoSubTab = () => setSubTab("photo");
const handleAddNoteKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (addNoteRef.current) {
if (!inputDisabled && addNoteRef.current) {
const note = addNoteRef.current.value.trim();
if ((note.length > 0) && (e.keyCode === 13)) {
e.preventDefault();
Expand All @@ -238,6 +240,9 @@ export const PhotoOrNoteField: React.FC<FieldProps> = props => {
}
};
const handleDeletePhotoOrNote = (item: IPhotoOrNote) => {
if (inputDisabled) {
return;
}
const newFormData = formData.slice();
const index = formData.indexOf(item);
newFormData.splice(index, 1);
Expand Down Expand Up @@ -269,8 +274,8 @@ export const PhotoOrNoteField: React.FC<FieldProps> = props => {
const renderNoteSubTab = () => {
return (
<div className={css.noteSubTab}>
<textarea className={css.addNote} ref={addNoteRef} placeholder="Add a note" onKeyUp={handleAddNoteKeyUp} />
{notes().map((note, index) => <Note key={index} note={note} deleteNote={handleDeletePhotoOrNote} />)}
{!inputDisabled && <textarea className={css.addNote} ref={addNoteRef} placeholder="Add a note" onKeyUp={handleAddNoteKeyUp} />}
{notes().map((note, index) => <Note key={index} note={note} deleteNote={handleDeletePhotoOrNote} inputDisabled={inputDisabled} />)}
</div>
);
};
Expand All @@ -290,6 +295,7 @@ export const PhotoOrNoteField: React.FC<FieldProps> = props => {
saveAll={handleSaveAll}
width={width}
height={height}
inputDisabled={inputDisabled}
/>
</>
);
Expand Down
1 change: 1 addition & 0 deletions src/shared/components/section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface IProps {
inputDisabled?: boolean;
setInputDisabled?: React.Dispatch<React.SetStateAction<boolean>>;
log?: (action: string, data?: object | undefined) => void;
reportMode?: boolean;
}

const SectionComponent: {[name in SectionComponentName]: SectionComponent} = {
Expand Down

0 comments on commit 3d15b03

Please sign in to comment.