diff --git a/gui/src/app/Project/ProjectDataModel.ts b/gui/src/app/Project/ProjectDataModel.ts index e85ef8f..c9c048e 100644 --- a/gui/src/app/Project/ProjectDataModel.ts +++ b/gui/src/app/Project/ProjectDataModel.ts @@ -90,13 +90,26 @@ const isProjectBase = (x: any): x is ProjectBase => { return true; }; +export enum DataSource { + GENERATED_BY_R = "generated_by_r", + GENERATED_BY_STALE_R = "generated_by_stale_r", + GENERATED_BY_PYTHON = "generated_by_python", + GENERATED_BY_STALE_PYTHON = "generated_by_stale_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 && // undefined = manually edited or unknown provenance + !Object.values(DataSource).includes(x.dataSource) + ) + return false; return true; }; @@ -128,7 +141,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..1a0d04f 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: "generateData"; content: string; dataSource: DataSource } | { type: "editFile"; content: string; @@ -69,9 +71,27 @@ const ProjectReducer = (s: ProjectDataModel, a: ProjectReducerAction) => { } case "commitFile": { const newState = { ...s }; + const newDataSource = confirmDataSourceForCommit( + s.meta.dataSource, + a.filename, + ); + if (newDataSource !== s.meta.dataSource) { + newState.meta = { ...s.meta, dataSource: newDataSource }; + } newState[a.filename] = s.ephemera[a.filename]; return newState; } + case "generateData": { + return { + ...s, + [ProjectKnownFiles.DATAFILE]: a.content, + ephemera: { + ...s.ephemera, + [ProjectKnownFiles.DATAFILE]: a.content, + }, + meta: { ...s.meta, dataSource: a.dataSource }, + }; + } case "setSamplingOpts": { return { ...s, samplingOpts: { ...s.samplingOpts, ...a.opts } }; } @@ -86,4 +106,25 @@ const ProjectReducer = (s: ProjectDataModel, a: ProjectReducerAction) => { } }; +const confirmDataSourceForCommit = ( + currentSource: DataSource | undefined, + editedFile: ProjectKnownFiles, +): DataSource | undefined => { + if (editedFile === ProjectKnownFiles.DATAFILE) return undefined; + if ( + editedFile === ProjectKnownFiles.DATAPYFILE && + currentSource === DataSource.GENERATED_BY_PYTHON + ) { + return DataSource.GENERATED_BY_STALE_PYTHON; + } + if ( + editedFile === ProjectKnownFiles.DATARFILE && + currentSource === DataSource.GENERATED_BY_R + ) { + return DataSource.GENERATED_BY_STALE_R; + } + + return currentSource; +}; + export default ProjectReducer; 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..830918b 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 } 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); @@ -21,11 +21,13 @@ const useDataGenState = () => { if (dataJson !== lastData.current) { lastData.current = dataJson; update({ - type: "editFile", + type: "generateData", content: dataJson, - filename: ProjectKnownFiles.DATAFILE, + dataSource: + source === "python" + ? DataSource.GENERATED_BY_PYTHON + : DataSource.GENERATED_BY_R, }); - update({ type: "commitFile", filename: ProjectKnownFiles.DATAFILE }); // 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..1682782 100644 --- a/gui/src/app/pages/HomePage/HomePage.tsx +++ b/gui/src/app/pages/HomePage/HomePage.tsx @@ -5,9 +5,11 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import StanFileEditor from "@SpComponents/StanFileEditor"; import TabWidget from "@SpComponents/TabWidget"; import TextEditor from "@SpComponents/TextEditor"; +import { ColorOptions, ToolbarItem } from "@SpComponents/ToolBar"; import { FileNames } from "@SpCore/FileMapping"; import { ProjectContext } from "@SpCore/ProjectContextProvider"; import { + DataSource, modelHasUnsavedChanges, ProjectKnownFiles, } from "@SpCore/ProjectDataModel"; @@ -15,10 +17,13 @@ import Sidebar, { drawerWidth } from "@SpPages/Sidebar"; import TopBar from "@SpPages/TopBar"; import DataPyWindow from "@SpScripting/DataGeneration/DataPyWindow"; import DataRWindow from "@SpScripting/DataGeneration/DataRWindow"; +import { unreachable } from "@SpUtil/unreachable"; import { FunctionComponent, + useCallback, useContext, useEffect, + useMemo, useRef, useState, } from "react"; @@ -92,6 +97,7 @@ type LeftViewProps = { const LeftView: FunctionComponent = () => { const { data, update } = useContext(ProjectContext); + return ( = () => { } readOnly={false} /> - - update({ - type: "commitFile", - filename: ProjectKnownFiles.DATAFILE, - }) + + + ); +}; + +const DataEditor: FunctionComponent = () => { + const { data, update } = useContext(ProjectContext); + + const dataIsEdited = useMemo(() => { + return data.dataFileContent !== data.ephemera.dataFileContent; + }, [data.dataFileContent, data.ephemera.dataFileContent]); + + const dataSourceDesc: undefined | { msg: string; color: ColorOptions } = + useMemo(() => { + if (dataIsEdited) return undefined; + + switch (data.meta.dataSource) { + case undefined: { + return undefined; } - editedText={data.ephemera.dataFileContent} - onSetEditedText={(content: string) => - update({ - type: "editFile", - content, - filename: ProjectKnownFiles.DATAFILE, - }) + case DataSource.GENERATED_BY_PYTHON: { + return { msg: "data.py", color: "info.main" }; } - readOnly={false} - contentOnEmpty={"Enter JSON data or use the data generation tab"} - /> - + case DataSource.GENERATED_BY_R: { + return { msg: "data.R", color: "info.main" }; + } + case DataSource.GENERATED_BY_STALE_PYTHON: { + return { msg: "a prior version of data.py.", color: "warning.main" }; + } + case DataSource.GENERATED_BY_STALE_R: { + return { msg: "a prior version of data.R.", color: "warning.main" }; + } + default: + return unreachable(data.meta.dataSource); + } + }, [dataIsEdited, data.meta.dataSource]); + + const dataMessage: ToolbarItem[] = useMemo(() => { + if (dataSourceDesc === undefined) { + return []; + } else { + return [ + { + type: "text", + label: `Data generated by ${dataSourceDesc.msg}`, + color: dataSourceDesc.color, + }, + ]; + } + }, [dataSourceDesc]); + + const onSetEditedText = useCallback( + (content: string) => { + update({ + type: "editFile", + content, + filename: ProjectKnownFiles.DATAFILE, + }); + }, + [update], + ); + + const onSaveText = useCallback(() => { + update({ + type: "commitFile", + filename: ProjectKnownFiles.DATAFILE, + }); + }, [update]); + + return ( + ); }; 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", () => { diff --git a/gui/test/app/Project/ProjectReducer.test.ts b/gui/test/app/Project/ProjectReducer.test.ts index 04468cc..6996783 100644 --- a/gui/test/app/Project/ProjectReducer.test.ts +++ b/gui/test/app/Project/ProjectReducer.test.ts @@ -1,4 +1,5 @@ import { + DataSource, getStringKnownFileKeys, initialDataModel, ProjectDataModel, @@ -130,6 +131,7 @@ describe("Project reducer", () => { const initialState = { ...permanentFiles, ephemera: { ...ephemeralFiles }, + meta: { dataSource: DataSource.GENERATED_BY_PYTHON }, } as any as ProjectDataModel; const commitAction: ProjectReducerAction = { type: "commitFile", @@ -144,6 +146,13 @@ describe("Project reducer", () => { result.ephemera[ProjectKnownFiles.DATAFILE], ); }); + test("Saving data.json clears data source", () => { + expect(initialState[ProjectKnownFiles.DATAFILE]).not.toEqual( + initialState.ephemera[ProjectKnownFiles.DATAFILE], + ); + const result = ProjectReducer(initialState, commitAction); + expect(result.meta.dataSource).toBeUndefined(); + }); test("Save action does not save non-chosen files", () => { const result = ProjectReducer(initialState, commitAction); expect(result[ProjectKnownFiles.STANFILE]).not.toEqual( @@ -154,6 +163,55 @@ describe("Project reducer", () => { const result = ProjectReducer(initialState, commitAction); expect(result.ephemera).toEqual(initialState.ephemera); }); + test("Saving data generation script updates status on data it generated", () => { + const pairs = [ + { + source: DataSource.GENERATED_BY_PYTHON, + newSource: DataSource.GENERATED_BY_STALE_PYTHON, + file: ProjectKnownFiles.DATAPYFILE, + }, + { + source: DataSource.GENERATED_BY_R, + newSource: DataSource.GENERATED_BY_STALE_R, + file: ProjectKnownFiles.DATARFILE, + }, + ]; + pairs.forEach((p) => { + const initial = { + ...initialState, + meta: { dataSource: p.source }, + } as any as ProjectDataModel; + const commit = { ...commitAction, filename: p.file }; + const result = ProjectReducer(initial, commit); + expect(result.meta.dataSource).toEqual(p.newSource); + }); + }); + test("Saving data generation script does not change status for data.json it didn't generate", () => { + const pairs = [ + { + source: DataSource.GENERATED_BY_PYTHON, + file: ProjectKnownFiles.DATAPYFILE, + }, + { + source: DataSource.GENERATED_BY_R, + file: ProjectKnownFiles.DATARFILE, + }, + ]; + const sources = Object.entries(DataSource); + pairs.forEach((p) => { + sources + .filter(([, value]) => value !== p.source) + .forEach((s) => { + const initial = { + ...initialState, + meta: { dataSource: s }, + } as any as ProjectDataModel; + const commit = { ...commitAction, filename: p.file }; + const result = ProjectReducer(initial, commit); + expect(result.meta.dataSource).toEqual(s); + }); + }); + }); }); describe("Updating sampling options", () => { @@ -220,4 +278,24 @@ describe("Project reducer", () => { expect(result).toBe(initialDataModel); }); }); + + describe("Generate data", () => { + test("Updates data file and ephemera with new data", () => { + const initialState = { + ...permanentFiles, + ephemera: { ...ephemeralFiles }, + meta: { dataSource: DataSource.GENERATED_BY_R }, + } as any as ProjectDataModel; + const newData = "generated data"; + const generateAction: ProjectReducerAction = { + type: "generateData", + content: newData, + dataSource: DataSource.GENERATED_BY_PYTHON, + }; + const result = ProjectReducer(initialState, generateAction); + expect(result[ProjectKnownFiles.DATAFILE]).toEqual(newData); + expect(result.ephemera[ProjectKnownFiles.DATAFILE]).toEqual(newData); + expect(result.meta.dataSource).toEqual(DataSource.GENERATED_BY_PYTHON); + }); + }); });