Skip to content

Commit

Permalink
Merge pull request #157 from concord-consortium/187809685-save-data-i…
Browse files Browse the repository at this point in the history
…n-interactive-state

feat: Save experiment data in interactive state [PT-187809685]
  • Loading branch information
dougmartin authored Jul 1, 2024
2 parents b7c8efd + 3d15b03 commit f21228d
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 f21228d

Please sign in to comment.