diff --git a/gui/src/app/Project/ProjectDataModel.ts b/gui/src/app/Project/ProjectDataModel.ts index 23eab70..c9c048e 100644 --- a/gui/src/app/Project/ProjectDataModel.ts +++ b/gui/src/app/Project/ProjectDataModel.ts @@ -92,7 +92,9 @@ const isProjectBase = (x: any): x is ProjectBase => { 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 = { @@ -104,7 +106,7 @@ 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 + x.dataSource !== undefined && // undefined = manually edited or unknown provenance !Object.values(DataSource).includes(x.dataSource) ) return false; diff --git a/gui/src/app/Project/ProjectReducer.ts b/gui/src/app/Project/ProjectReducer.ts index e791c27..1a0d04f 100644 --- a/gui/src/app/Project/ProjectReducer.ts +++ b/gui/src/app/Project/ProjectReducer.ts @@ -71,8 +71,12 @@ const ProjectReducer = (s: ProjectDataModel, a: ProjectReducerAction) => { } case "commitFile": { const newState = { ...s }; - if (a.filename === ProjectKnownFiles.DATAFILE) { - newState.meta = { ...s.meta, dataSource: undefined }; + 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; @@ -102,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/Project/ProjectSerialization.ts b/gui/src/app/Project/ProjectSerialization.ts index 1912466..c9cfd10 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 => { - let newMeta = JSON.parse(json); + const newMeta = JSON.parse(json); if (!isProjectMetaData(newMeta)) { throw Error("Deserialized meta is not valid"); } diff --git a/gui/src/app/pages/HomePage/HomePage.tsx b/gui/src/app/pages/HomePage/HomePage.tsx index c407296..1682782 100644 --- a/gui/src/app/pages/HomePage/HomePage.tsx +++ b/gui/src/app/pages/HomePage/HomePage.tsx @@ -5,6 +5,7 @@ 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 { @@ -16,6 +17,7 @@ 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, @@ -26,7 +28,6 @@ import { useState, } from "react"; import SamplingWindow from "./SamplingWindow/SamplingWindow"; -import { ToolbarItem } from "@SpComponents/ToolBar"; type Props = { // @@ -124,30 +125,51 @@ const LeftView: FunctionComponent = () => { ); }; -const DataEditor: FunctionComponent<{}> = () => { +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; + } + case DataSource.GENERATED_BY_PYTHON: { + return { msg: "data.py", color: "info.main" }; + } + 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 (data.meta.dataSource === undefined || dataIsEdited) { + if (dataSourceDesc === undefined) { return []; } else { return [ { type: "text", - label: - "Data is generated by data." + - (data.meta.dataSource === DataSource.GENERATED_BY_PYTHON - ? "py" - : "R"), - color: "info.main", + label: `Data generated by ${dataSourceDesc.msg}`, + color: dataSourceDesc.color, }, ]; } - }, [data.meta.dataSource, dataIsEdited]); + }, [dataSourceDesc]); const onSetEditedText = useCallback( (content: string) => { diff --git a/gui/test/app/Project/ProjectReducer.test.ts b/gui/test/app/Project/ProjectReducer.test.ts index b868146..6996783 100644 --- a/gui/test/app/Project/ProjectReducer.test.ts +++ b/gui/test/app/Project/ProjectReducer.test.ts @@ -145,6 +145,12 @@ describe("Project reducer", () => { expect(result[ProjectKnownFiles.DATAFILE]).toEqual( 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", () => { @@ -157,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", () => {