Skip to content

Commit

Permalink
Merge pull request #264 from flatironinstitute/show-unsaved-tooltip
Browse files Browse the repository at this point in the history
Show which files are unsaved on disabled save/load buttons
  • Loading branch information
WardBrian authored Jan 27, 2025
2 parents 7232838 + eb70466 commit a1344b6
Show file tree
Hide file tree
Showing 8 changed files with 94 additions and 90 deletions.
11 changes: 7 additions & 4 deletions gui/src/app/Project/ProjectDataModel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import baseObjectCheck from "@SpUtil/baseObjectCheck";
import { ProjectFileMap } from "@SpCore/FileMapping";

export enum ProjectKnownFiles {
STANFILE = "stanFileContent",
Expand Down Expand Up @@ -177,10 +178,12 @@ export const modelHasUnsavedChanges = (data: ProjectDataModel): boolean => {
return stringFileKeys.some((k) => data[k] !== data.ephemera[k]);
};

export const modelHasUnsavedDataFileChanges = (
data: ProjectDataModel,
): boolean => {
return data.dataFileContent !== data.ephemera.dataFileContent;
export const unsavedChangesString = (data: ProjectDataModel): string => {
const stringFileKeys = getStringKnownFileKeys();
return stringFileKeys
.filter((k) => data[k] !== data.ephemera[k])
.map((k) => ProjectFileMap[k])
.join(", ");
};

export const stringifyField = (
Expand Down
29 changes: 18 additions & 11 deletions gui/src/app/RunPanel/RunPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { FunctionComponent, useCallback, useContext, useMemo } from "react";
import Button from "@mui/material/Button";
import { CompileContext } from "@SpCompilation/CompileContextProvider";
import { ProjectContext } from "@SpCore/ProjectContextProvider";
import { SamplingOpts, modelHasUnsavedChanges } from "@SpCore/ProjectDataModel";
import { SamplingOpts } from "@SpCore/ProjectDataModel";
import StanSampler from "@SpStanSampler/StanSampler";
import { StanRun } from "@SpStanSampler/useStanSampler";
import CompiledRunPanel from "./CompiledRunPanel";
Expand All @@ -13,24 +13,21 @@ import Tooltip from "@mui/material/Tooltip";
type RunPanelProps = {
sampler?: StanSampler;
latestRun: StanRun;
data: string;
dataIsSaved: boolean;
samplingOpts: SamplingOpts;
};

const RunPanel: FunctionComponent<RunPanelProps> = ({
sampler,
latestRun,
data,
dataIsSaved,
samplingOpts,
}) => {
const { status: runStatus, errorMessage, progress } = latestRun;
const { data: projectData } = useContext(ProjectContext);

const handleRun = useCallback(async () => {
if (!sampler) return;
sampler.sample(data, samplingOpts);
}, [sampler, data, samplingOpts]);
sampler.sample(projectData.dataFileContent, samplingOpts);
}, [sampler, projectData.dataFileContent, samplingOpts]);

const cancelRun = useCallback(() => {
if (!sampler) return;
Expand All @@ -40,15 +37,25 @@ const RunPanel: FunctionComponent<RunPanelProps> = ({
const { compile, compileStatus, validSyntax, isConnected } =
useContext(CompileContext);

const { data: projectData } = useContext(ProjectContext);
const modelIsPresent = useMemo(() => {
return projectData.stanFileContent.trim();
}, [projectData.stanFileContent]);

const modelIsSaved = useMemo(() => {
return projectData.stanFileContent === projectData.ephemera.stanFileContent;
}, [projectData.ephemera.stanFileContent, projectData.stanFileContent]);

const tooltip = useMemo(() => {
if (!validSyntax) return "Syntax error";
if (!isConnected) return "Not connected to compilation server";
if (!projectData.stanFileContent.trim()) return "No model to compile";
if (modelHasUnsavedChanges(projectData)) return "Model has unsaved changes";
if (!modelIsPresent) return "No model to compile";
if (!modelIsSaved) return "Model has unsaved changes";
return "";
}, [isConnected, projectData, validSyntax]);
}, [isConnected, modelIsPresent, modelIsSaved, validSyntax]);

const dataIsSaved = useMemo(() => {
return projectData.dataFileContent === projectData.ephemera.dataFileContent;
}, [projectData.dataFileContent, projectData.ephemera.dataFileContent]);

if (!dataIsSaved) {
return <div className="RunPanelPadded">Data not saved</div>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ import TextField from "@mui/material/TextField";
import TableRow from "@mui/material/TableRow";
import Link from "@mui/material/Link";

type SaveProjectWindowProps = {
type ExportProjectWindowProps = {
onClose: () => void;
};

const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
const ExportProjectWindow: FunctionComponent<ExportProjectWindowProps> = ({
onClose,
}) => {
const { data, update } = useContext(ProjectContext);
Expand Down Expand Up @@ -77,15 +77,15 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
);
}}
>
Save to .zip file
Export to .zip file
</Button>
&nbsp;
<Button
onClick={() => {
setExportingToGist(true);
}}
>
Save to GitHub Gist
Export to GitHub Gist
</Button>
<Button
onClick={() => {
Expand Down Expand Up @@ -143,13 +143,13 @@ const GistExportView: FunctionComponent<GistExportViewProps> = ({

return (
<div className="GistExplainer">
<h3>Save to GitHub Gist</h3>
<h3>Export to GitHub Gist</h3>
<p>
In order to save this project as a GitHub Gist, you will need to provide
a GitHub Personal Access Token. This token will be used to authenticate
with GitHub and create a new Gist with the files in this project. To
create a new Personal Access Token granting permission to read/write
your Gists,{" "}
In order to export this project as a GitHub Gist, you will need to
provide a GitHub Personal Access Token. This token will be used to
authenticate with GitHub and create a new Gist with the files in this
project. To create a new Personal Access Token granting permission to
read/write your Gists,{" "}
<Link
href="https://github.com/settings/tokens/new?description=Stan%20Playground&scopes=gist"
target="_blank"
Expand Down Expand Up @@ -183,7 +183,7 @@ const GistExportView: FunctionComponent<GistExportViewProps> = ({
{!gistUrl && (
<div>
<Button onClick={handleExport} disabled={!gitHubPersonalAccessToken}>
Save to GitHub Gist
Export to GitHub Gist
</Button>
&nbsp;
<Button onClick={onClose}>Cancel</Button>
Expand All @@ -192,7 +192,7 @@ const GistExportView: FunctionComponent<GistExportViewProps> = ({
{gistUrl && (
<div>
<p>
Successfully saved to GitHub Gist:{" "}
Successfully exported to GitHub Gist:{" "}
<Link href={gistUrl} target="_blank" rel="noreferrer">
{gistUrl}
</Link>
Expand Down Expand Up @@ -395,4 +395,4 @@ const makeSPShareableLinkFromGistUrl = (gistUrl: string) => {
return url;
};

export default SaveProjectWindow;
export default ExportProjectWindow;
11 changes: 2 additions & 9 deletions gui/src/app/pages/HomePage/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ 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";
import { DataSource, ProjectKnownFiles } from "@SpCore/ProjectDataModel";
import Sidebar, { drawerWidth } from "@SpPages/Sidebar";
import TopBar from "@SpPages/TopBar";
import DataPyWindow from "@SpScripting/DataGeneration/DataPyWindow";
Expand Down Expand Up @@ -60,10 +56,7 @@ const HomePage: FunctionComponent<Props> = () => {
<Box display="flex" flexDirection="column" height="100%">
<TopBar title={data.meta.title} onSetCollapsed={setLeftPanelCollapsed} />

<Sidebar
collapsed={leftPanelCollapsed}
hasUnsavedChanges={modelHasUnsavedChanges(data)}
/>
<Sidebar collapsed={leftPanelCollapsed} />

<MovingBox open={leftPanelCollapsed} flex="1" minHeight="0">
<Split minPrimarySize="80px" minSecondarySize="120px">
Expand Down
7 changes: 1 addition & 6 deletions gui/src/app/pages/HomePage/SamplingWindow/SamplingWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ import SamplerOutputView from "@SpComponents/SamplerOutputView";
import SamplingOptsPanel from "@SpComponents/SamplingOptsPanel";
import TabWidget from "@SpComponents/TabWidget";
import { ProjectContext } from "@SpCore/ProjectContextProvider";
import {
modelHasUnsavedDataFileChanges,
SamplingOpts,
} from "@SpCore/ProjectDataModel";
import { SamplingOpts } from "@SpCore/ProjectDataModel";
import AnalysisPyWindow from "@SpScripting/Analysis/AnalysisPyWindow";
import AnalysisRWindow from "@SpScripting/Analysis/AnalysisRWindow";
import useStanSampler, { StanRun } from "@SpStanSampler/useStanSampler";
Expand Down Expand Up @@ -48,8 +45,6 @@ const SamplingWindow: FunctionComponent<SamplingWindowProps> = () => {
<RunPanel
sampler={sampler}
latestRun={latestRun}
data={data.dataFileContent}
dataIsSaved={!modelHasUnsavedDataFileChanges(data)}
samplingOpts={data.samplingOpts}
/>
</Grid>
Expand Down
81 changes: 49 additions & 32 deletions gui/src/app/pages/HomePage/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ import CloseableDialog, {
useDialogControls,
} from "@SpComponents/CloseableDialog";
import { ProjectContext } from "@SpCore/ProjectContextProvider";
import { modelHasUnsavedChanges } from "@SpCore/ProjectDataModel";
import { unsavedChangesString } from "@SpCore/ProjectDataModel";
import LoadProjectWindow from "@SpPages/LoadProjectWindow";
import SaveProjectWindow from "@SpPages/SaveProjectWindow";
import ExportProjectWindow from "@SpPages/ExportProjectWindow";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import Drawer from "@mui/material/Drawer";
import Link from "@mui/material/Link";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Toolbar from "@mui/material/Toolbar";
import Tooltip from "@mui/material/Tooltip";
import { FunctionComponent, useContext, useMemo } from "react";
import { useNavigate } from "react-router-dom";

type Sidebar = {
hasUnsavedChanges: boolean;
type SidebarProps = {
collapsed: boolean;
};

Expand All @@ -33,21 +33,27 @@ const exampleLinks = [

export const drawerWidth = 180;

const Sidebar: FunctionComponent<Sidebar> = ({
hasUnsavedChanges,
collapsed,
}) => {
const Sidebar: FunctionComponent<SidebarProps> = ({ collapsed }) => {
// note: this is close enough to pass in directly if we wish
const { data } = useContext(ProjectContext);

const navigate = useNavigate();

const dataModified = useMemo(() => modelHasUnsavedChanges(data), [data]);
const { dataModified, unsavedString } = useMemo(() => {
const s = unsavedChangesString(data);
if (s.length === 0) {
return { dataModified: false, unsavedString: "" };
}
return {
dataModified: true,
unsavedString: `The following files have unsaved changes: ${s}`,
};
}, [data]);

const {
open: saveProjectVisible,
handleOpen: saveProjectOpen,
handleClose: saveProjectClose,
open: exportProjectVisible,
handleOpen: exportProjectOpen,
handleClose: exportProjectClose,
} = useDialogControls();
const {
open: loadProjectVisible,
Expand Down Expand Up @@ -100,23 +106,34 @@ const Sidebar: FunctionComponent<Sidebar> = ({

<List>
<ListItem key="load-project">
<Button
variant="outlined"
onClick={loadProjectOpen}
disabled={hasUnsavedChanges}
>
Load project
</Button>
<Tooltip title={unsavedString}>
<span style={{ width: "100%" }}>
<Button
fullWidth
variant="outlined"
onClick={loadProjectOpen}
disabled={dataModified}
>
Load project
</Button>
</span>
</Tooltip>
</ListItem>

<ListItem key="save-project">
<Button
variant="outlined"
onClick={saveProjectOpen}
disabled={hasUnsavedChanges}
>
Save project
</Button>
<ListItem key="export-project">
<Tooltip title={unsavedString}>
{/* span only exists so that this is still hover-able when disabled */}
<span style={{ width: "100%" }}>
<Button
fullWidth
variant="outlined"
onClick={exportProjectOpen}
disabled={dataModified}
>
Export project
</Button>
</span>
</Tooltip>
</ListItem>
</List>
</div>
Expand All @@ -130,12 +147,12 @@ const Sidebar: FunctionComponent<Sidebar> = ({
<LoadProjectWindow onClose={loadProjectClose} />
</CloseableDialog>
<CloseableDialog
title="Save this project"
id="saveProjectDialog"
open={saveProjectVisible}
handleClose={saveProjectClose}
title="Export this project"
id="exportProjectDialog"
open={exportProjectVisible}
handleClose={exportProjectClose}
>
<SaveProjectWindow onClose={saveProjectClose} />
<ExportProjectWindow onClose={exportProjectClose} />
</CloseableDialog>
</Drawer>
);
Expand Down
5 changes: 3 additions & 2 deletions gui/src/localStyles.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,17 @@

.Sidebar h3 {
padding: 0px;
margin-left: 12px;
padding-left: 10px;
margin-bottom: 0px;
margin-top: 0px;
}

.Sidebar li {
margin-left: 12px;
margin-top: 0px;
margin-bottom: 5px;
padding: 0px;
padding-left: 10px;
padding-right: 10px;
}

/* File Upload */
Expand Down
14 changes: 1 addition & 13 deletions gui/test/app/Project/ProjectDataModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
isProjectMetaData,
isSamplingOpts,
modelHasUnsavedChanges,
modelHasUnsavedDataFileChanges,
parseSamplingOpts,
persistStateToEphemera,
ProjectKnownFiles,
Expand Down Expand Up @@ -319,18 +318,7 @@ describe("Model saving and save state", () => {
});
});
});
describe("model has unsaved data file changes", () => {
test("Returns false if data file matches ephemeral", () => {
expect(modelHasUnsavedDataFileChanges(goodDataModel as any)).toBe(false);
});
test("Returns true if data file does not match ephemeral", () => {
const discrepant = {
...goodDataModel,
ephemera: { ...goodDataModel.ephemera, dataFileContent: "foo" },
};
expect(modelHasUnsavedDataFileChanges(discrepant as any)).toBe(true);
});
});

describe("Persisting state to ephemera", () => {
test("After executing, ephemera state matches data state", () => {
const start: any = {
Expand Down

0 comments on commit a1344b6

Please sign in to comment.