diff --git a/gui/src/app/Project/ProjectDataModel.ts b/gui/src/app/Project/ProjectDataModel.ts index e85ef8f..23eab70 100644 --- a/gui/src/app/Project/ProjectDataModel.ts +++ b/gui/src/app/Project/ProjectDataModel.ts @@ -90,13 +90,24 @@ const isProjectBase = (x: any): x is ProjectBase => { return true; }; +export enum DataSource { + GENERATED_BY_R = "generated_by_r", + GENERATED_BY_PYTHON = "generated_by_python", +} + type ProjectMetadata = { title: string; + dataSource?: DataSource; }; export const isProjectMetaData = (x: any): x is ProjectMetadata => { if (!baseObjectCheck(x)) return false; if (typeof x.title !== "string") return false; + if ( + x.dataSource !== undefined && // allow undefined for backwards compatibility + !Object.values(DataSource).includes(x.dataSource) + ) + return false; return true; }; @@ -128,7 +139,7 @@ export const isProjectDataModel = (x: any): x is ProjectDataModel => { export type ProjectPersistentDataModel = Omit; export const initialDataModel: ProjectDataModel = { - meta: { title: "Untitled" }, + meta: { title: "Untitled", dataSource: undefined }, ephemera: { stanFileContent: "", dataFileContent: "", diff --git a/gui/src/app/Project/ProjectReducer.ts b/gui/src/app/Project/ProjectReducer.ts index 4a2945d..032d8cd 100644 --- a/gui/src/app/Project/ProjectReducer.ts +++ b/gui/src/app/Project/ProjectReducer.ts @@ -1,5 +1,6 @@ import { FieldsContentsMap } from "@SpCore/FileMapping"; import { + DataSource, initialDataModel, ProjectDataModel, ProjectKnownFiles, @@ -24,6 +25,7 @@ export type ProjectReducerAction = type: "retitle"; title: string; } + | { type: "setDataSource"; dataSource: DataSource | undefined } | { type: "editFile"; content: string; @@ -81,6 +83,9 @@ const ProjectReducer = (s: ProjectDataModel, a: ProjectReducerAction) => { case "clear": { return initialDataModel; } + case "setDataSource": { + return { ...s, meta: { ...s.meta, dataSource: a.dataSource } }; + } default: return unreachable(a); } diff --git a/gui/src/app/Project/ProjectSerialization.ts b/gui/src/app/Project/ProjectSerialization.ts index c9cfd10..1912466 100644 --- a/gui/src/app/Project/ProjectSerialization.ts +++ b/gui/src/app/Project/ProjectSerialization.ts @@ -112,7 +112,7 @@ const loadMetaFromString = ( json: string, clearExisting: boolean = false, ): ProjectDataModel => { - const newMeta = JSON.parse(json); + let newMeta = JSON.parse(json); if (!isProjectMetaData(newMeta)) { throw Error("Deserialized meta is not valid"); } diff --git a/gui/src/app/Scripting/DataGeneration/DataPyWindow.tsx b/gui/src/app/Scripting/DataGeneration/DataPyWindow.tsx index bd6f524..4f2f8bf 100644 --- a/gui/src/app/Scripting/DataGeneration/DataPyWindow.tsx +++ b/gui/src/app/Scripting/DataGeneration/DataPyWindow.tsx @@ -22,7 +22,7 @@ const handleHelp = () => ); const DataPyWindow: FunctionComponent = () => { - const { consoleRef, status, onStatus, onData } = useDataGenState(); + const { consoleRef, status, onStatus, onData } = useDataGenState("python"); const callbacks = useMemo( () => ({ diff --git a/gui/src/app/Scripting/DataGeneration/DataRWindow.tsx b/gui/src/app/Scripting/DataGeneration/DataRWindow.tsx index b383932..d37ae26 100644 --- a/gui/src/app/Scripting/DataGeneration/DataRWindow.tsx +++ b/gui/src/app/Scripting/DataGeneration/DataRWindow.tsx @@ -19,7 +19,7 @@ const handleHelp = () => ); const DataRWindow: FunctionComponent = () => { - const { consoleRef, status, onStatus, onData } = useDataGenState(); + const { consoleRef, status, onStatus, onData } = useDataGenState("r"); const { run } = useWebR({ consoleRef, onStatus, onData }); const handleRun = useCallback( diff --git a/gui/src/app/Scripting/DataGeneration/useDataGenState.ts b/gui/src/app/Scripting/DataGeneration/useDataGenState.ts index 892d859..25c74cc 100644 --- a/gui/src/app/Scripting/DataGeneration/useDataGenState.ts +++ b/gui/src/app/Scripting/DataGeneration/useDataGenState.ts @@ -1,12 +1,12 @@ import { useCallback, useContext, useRef, useState } from "react"; -import { ProjectKnownFiles } from "@SpCore/ProjectDataModel"; +import { DataSource, ProjectKnownFiles } from "@SpCore/ProjectDataModel"; import { writeConsoleOutToDiv } from "@SpScripting/OutputDivUtils"; import { InterpreterStatus } from "@SpScripting/InterpreterTypes"; import { ProjectContext } from "@SpCore/ProjectContextProvider"; // A custom hook to share logic between the Python and R data generation windows // This contains the output div ref, the interpreter state, and the callback to update the data. -const useDataGenState = () => { +const useDataGenState = (source: "python" | "r") => { const [status, setStatus] = useState("idle"); const consoleRef = useRef(null); @@ -26,6 +26,13 @@ const useDataGenState = () => { filename: ProjectKnownFiles.DATAFILE, }); update({ type: "commitFile", filename: ProjectKnownFiles.DATAFILE }); + update({ + type: "setDataSource", + dataSource: + source === "python" + ? DataSource.GENERATED_BY_PYTHON + : DataSource.GENERATED_BY_R, + }); // Use "stan-playground" prefix to distinguish from console output of the running code writeConsoleOutToDiv( consoleRef, diff --git a/gui/src/app/pages/HomePage/HomePage.tsx b/gui/src/app/pages/HomePage/HomePage.tsx index 6e1ffa5..b1202c9 100644 --- a/gui/src/app/pages/HomePage/HomePage.tsx +++ b/gui/src/app/pages/HomePage/HomePage.tsx @@ -8,6 +8,7 @@ import TextEditor from "@SpComponents/TextEditor"; import { FileNames } from "@SpCore/FileMapping"; import { ProjectContext } from "@SpCore/ProjectContextProvider"; import { + DataSource, modelHasUnsavedChanges, ProjectKnownFiles, } from "@SpCore/ProjectDataModel"; @@ -17,12 +18,15 @@ import DataPyWindow from "@SpScripting/DataGeneration/DataPyWindow"; import DataRWindow from "@SpScripting/DataGeneration/DataRWindow"; import { FunctionComponent, + useCallback, useContext, useEffect, + useMemo, useRef, useState, } from "react"; import SamplingWindow from "./SamplingWindow/SamplingWindow"; +import { ToolbarItem } from "@SpComponents/ToolBar"; type Props = { // @@ -92,6 +96,7 @@ type LeftViewProps = { const LeftView: FunctionComponent = () => { const { data, update } = useContext(ProjectContext); + return ( = () => { } readOnly={false} /> - - update({ - type: "commitFile", - filename: ProjectKnownFiles.DATAFILE, - }) - } - editedText={data.ephemera.dataFileContent} - onSetEditedText={(content: string) => - update({ - type: "editFile", - content, - filename: ProjectKnownFiles.DATAFILE, - }) - } - readOnly={false} - contentOnEmpty={"Enter JSON data or use the data generation tab"} - /> + ); }; +const DataEditor: FunctionComponent<{}> = () => { + const { data, update } = useContext(ProjectContext); + + const dataIsEdited = useMemo(() => { + return data.dataFileContent !== data.ephemera.dataFileContent; + }, [data.dataFileContent, data.ephemera.dataFileContent]); + + const dataMessage: ToolbarItem[] = useMemo(() => { + if (data.meta.dataSource === undefined || dataIsEdited) { + return []; + } else { + return [ + { + type: "text", + label: + "Data is generated by data." + + (data.meta.dataSource === DataSource.GENERATED_BY_PYTHON + ? "py" + : "R"), + color: "info.main", + }, + ]; + } + }, [data.meta.dataSource, dataIsEdited]); + + const onSetEditedText = useCallback( + (content: string) => { + update({ + type: "editFile", + content, + filename: ProjectKnownFiles.DATAFILE, + }); + }, + [update], + ); + + const onSaveText = useCallback(() => { + update({ + type: "commitFile", + filename: ProjectKnownFiles.DATAFILE, + }); + update({ + type: "setDataSource", + dataSource: undefined, + }); + }, [update]); + + return ( + + ); +}; + // adapted from https://mui.com/material-ui/react-drawer/#persistent-drawer const MovingBox = styled(Box, { shouldForwardProp: (prop) => prop !== "open", diff --git a/gui/test/app/Project/ProjectDataModel.test.ts b/gui/test/app/Project/ProjectDataModel.test.ts index 73dc7e7..1783488 100644 --- a/gui/test/app/Project/ProjectDataModel.test.ts +++ b/gui/test/app/Project/ProjectDataModel.test.ts @@ -1,4 +1,5 @@ import { + DataSource, defaultSamplingOpts, exportedForTesting, getStringKnownFileKeys, @@ -248,6 +249,23 @@ describe("Project data model type guards", () => { expect(isProjectMetaData({ title: 6 })).toBe(false); expect(isProjectMetaData({ no_title: "title" })).toBe(false); }); + test("Returns true for valid data source", () => { + expect( + isProjectMetaData({ + title: "title", + dataSource: DataSource.GENERATED_BY_PYTHON, + }), + ).toBe(true); + expect( + isProjectMetaData({ + title: "title", + dataSource: DataSource.GENERATED_BY_R, + }), + ).toBe(true); + }); + test("Returns false on bad data source", () => { + expect(isProjectMetaData({ title: "title", dataSource: 1 })).toBe(false); + }); }); describe("Project ephemeral-data typeguard", () => { test("Returns true for valid project files object", () => {