Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import/export JSON for models and diagrams #332

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from "./name_input";
export * from "./panel";
export * from "./resizable";
export * from "./rich_text_editor";
export * from "./json_import";
55 changes: 55 additions & 0 deletions packages/frontend/src/components/json_import.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.json_import {
min-width: 20em;
max-width: 80vw;
margin: 0 auto;
padding: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #f9f9f9;
width: auto;
}

.json_import .flex {
display: flex;
flex-direction: column;
gap: 1rem;
}

.json_import label {
font-weight: 500;
}

.json_import input[type="file"] {
border: 1px solid #ccc;
padding: 0.5rem;
border-radius: 4px;
}

.json_import textarea {
border: 1px solid #ccc;
padding: 0.5rem;
border-radius: 4px;
font-family: monospace;
resize: vertical;
min-height: 10rem;
max-height: 80vh;
}

.json_import button {
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}

.json_import button:hover {
background-color: #0056b3;
}

.json_import .error {
color: red;
margin-top: 1rem;
}
109 changes: 109 additions & 0 deletions packages/frontend/src/components/json_import.tsx
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've put this in components and export in util because export doesn't actually build a component but it needs to be used by model_menu, diagram_menu, and analysis_menu. Not sure if that's the right file structure.

Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { createSignal } from "solid-js";
import type { Document } from "../api";
import "./json_import.css";

interface JsonImportProps<T extends string> {
onImport: (data: Document<T>) => void;
validate?: (data: Document<T>) => boolean | string;
}

export const JsonImport = <T extends string>(props: JsonImportProps<T>) => {
const [error, setError] = createSignal<string | null>(null);
KevinDCarlson marked this conversation as resolved.
Show resolved Hide resolved
const [pasteValue, setPasteValue] = createSignal("");
jmoggr marked this conversation as resolved.
Show resolved Hide resolved

const handleError = (e: unknown) => {
setError(e instanceof Error ? e.message : "Unknown error occurred");
};

const validateAndImport = (jsonString: string) => {
try {
// Parse JSON
const data = JSON.parse(jsonString);

// Run custom validation if provided
if (props.validate) {
const validationResult = props.validate(data);
if (typeof validationResult === "string") {
setError(validationResult);
return;
}
}

// Clear any previous errors and import
setError(null);
props.onImport(data);
setPasteValue(""); // Clear paste area after successful import
} catch (e) {
handleError(e);
}
};

// Handle file upload
const handleFileUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;

try {
const file = input.files[0];

// Validate file type
if (!(file?.type === "application/json") && !file?.name.endsWith(".json")) {
jmoggr marked this conversation as resolved.
Show resolved Hide resolved
throw new Error("Please upload a JSON file");
}

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: you are absolutely going to want to enforce this on the backend as well, and for that it would be best to use the same constant. but that is a later problem

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added an issue for this. #408

if (file.size > MAX_FILE_SIZE) {
throw new Error("File size exceeds 5MB limit");
}

const text = await file?.text();
validateAndImport(text);

// Reset file input
input.value = "";
} catch (e) {
handleError(e);
}
jmoggr marked this conversation as resolved.
Show resolved Hide resolved
};

// Handle paste
const handlePaste = () => {
if (!pasteValue().trim()) {
setError("Please enter some JSON");
return;
}
validateAndImport(pasteValue());
};

const handleInput = (event: Event) => {
const textarea = event.target as HTMLTextAreaElement;
setPasteValue(textarea.value);
};

return (
<div class="json_import">
{/* File upload */}
<div class="flex">
<label>Import from file:</label>
<input type="file" accept=".json,application/json" onChange={handleFileUpload} />
</div>

{/* JSON paste */}
<div class="flex">
<label>Or paste JSON:</label>
<textarea
value={pasteValue()}
onInput={handleInput}
onPaste={handleInput}
placeholder="Paste your JSON here..."
/>
<button onClick={handlePaste} aria-label="Import JSON">
jmoggr marked this conversation as resolved.
Show resolved Hide resolved
Import Pasted JSON
</button>
</div>

{/* Error display */}
{error() && <div class="error">{error()}</div>}
</div>
);
};
19 changes: 19 additions & 0 deletions packages/frontend/src/diagram/diagram_menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ import {
createDiagramFromDocument,
} from "./document";

import { copyToClipboard, downloadJson } from "../util/json_export";

import ChartSpline from "lucide-solid/icons/chart-spline";
import CopyToClipboard from "lucide-solid/icons/clipboard-copy";
import Copy from "lucide-solid/icons/copy";
import Export from "lucide-solid/icons/download";
import FilePlus from "lucide-solid/icons/file-plus";

/** Hamburger menu for a diagram in a model. */
Expand Down Expand Up @@ -59,6 +63,13 @@ export function DiagramMenuItems(props: {
navigate(`/diagram/${newRef}`);
};

//Can this be less repetitive?
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i.e. I'm wondering whether we should have an overarching Menu type that extends to DiagramMenu etc. Seems like a fair amount of duplicated boilerplate otherwise.

Copy link
Collaborator

@jmoggr jmoggr Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: I took a shot at this, it was less scary than I initially thought. PR is here

old:

Based off of my limited understanding, I feel like we should hold off on merging them.

  • The menus are different enough that the abstraction currently would not be much more than an if statement for each menu item, which I don't think is worth the additional complexity.
    - We could fix that by defining generic interfaces that work for both models and diagrams (like what you've done for the import), but that's a non-zero amount of work and...
  • It's not obvious how much similarity there will be between the two menus going forward, and if they keep diverging an abstraction would become increasingly costly to maintain.

An alternative might be to create reusable components for menu items which can be easily shared. In this case it would be something like which add the 2 menu items and there handler.

Composition over inheritance and premature optimization is generally bad

const onDownloadJSON = (diagram: DiagramDocument) => {
downloadJson(JSON.stringify(diagram), `${diagram.name}.json`);
};
const onCopy = (diagram: DiagramDocument) => {
copyToClipboard(JSON.stringify(diagram));
};
return (
<>
<MenuItem onSelect={() => onNewDiagram(props.liveDiagram.liveModel.refId)}>
Expand All @@ -74,6 +85,14 @@ export function DiagramMenuItems(props: {
<Copy />
<MenuItemLabel>{"Duplicate diagram"}</MenuItemLabel>
</MenuItem>
<MenuItem onSelect={() => onDownloadJSON(props.liveDiagram.liveDoc.doc)}>
<Export />
<MenuItemLabel>{"Export notebook"}</MenuItemLabel>
</MenuItem>
<MenuItem onSelect={() => onCopy(props.liveDiagram.liveDoc.doc)}>
<CopyToClipboard />
<MenuItemLabel>{"Copy to clipboard"}</MenuItemLabel>
</MenuItem>
</>
);
}
17 changes: 17 additions & 0 deletions packages/frontend/src/model/model_menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import { createAnalysis } from "../analysis/document";
import { type StableRef, useApi } from "../api";
import { createDiagram } from "../diagram/document";
import { AppMenu, MenuItem, MenuItemLabel, MenuSeparator, NewModelItem } from "../page";
import { copyToClipboard, downloadJson } from "../util/json_export";
import { type LiveModelDocument, type ModelDocument, createModel } from "./document";

import ChartSpline from "lucide-solid/icons/chart-spline";
import CopyToClipboard from "lucide-solid/icons/clipboard-copy";
import Copy from "lucide-solid/icons/copy";
import Export from "lucide-solid/icons/download";
import Network from "lucide-solid/icons/network";

/** Hamburger menu for a model of a double theory. */
Expand Down Expand Up @@ -54,6 +57,12 @@ export function ModelMenuItems(props: {
});
navigate(`/model/${newRef}`);
};
const onDownloadJSON = (model: ModelDocument) => {
downloadJson(JSON.stringify(model), `${model.name}.json`);
};
const onCopy = (model: ModelDocument) => {
copyToClipboard(JSON.stringify(model));
};

return (
<>
Expand All @@ -73,6 +82,14 @@ export function ModelMenuItems(props: {
<Copy />
<MenuItemLabel>{"Duplicate model"}</MenuItemLabel>
</MenuItem>
<MenuItem onSelect={() => onDownloadJSON(props.liveModel.liveDoc.doc)}>
<Export />
<MenuItemLabel>{"Export notebook"}</MenuItemLabel>
</MenuItem>
<MenuItem onSelect={() => onCopy(props.liveModel.liveDoc.doc)}>
<CopyToClipboard />
<MenuItemLabel>{"Copy to clipboard"}</MenuItemLabel>
</MenuItem>
</>
);
}
64 changes: 64 additions & 0 deletions packages/frontend/src/page/import.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useNavigate } from "@solidjs/router";
import invariant from "tiny-invariant";
import { type Document, useApi } from "../api";
import { JsonImport } from "../components";
import { type DiagramDocument, createDiagramFromDocument } from "../diagram";
import { type ModelDocument, createModel } from "../model";

type ImportableDocument = ModelDocument | DiagramDocument;
function isImportableDocument(doc: Document<string>): doc is ImportableDocument {
return doc.type === "model" || doc.type === "diagram";
}

export function Import(props: { onComplete?: () => void }) {
const api = useApi();
const navigate = useNavigate();
const handleImport = async (data: Document<string>) => {
invariant(
isImportableDocument(data),
"Analysis and other document types cannot be imported at this time.",
);

switch (data.type) {
case "model": {
const newRef = await createModel(api, data);
try {
navigate(`/model/${newRef}`);
} catch (e) {
throw new Error(`Failed to navigate to new ${data.type}: ${e}`);
}
break;
}

case "diagram": {
const newRef = await createDiagramFromDocument(api, data);
try {
navigate(`/diagram/${newRef}`);
} catch (e) {
throw new Error(`Failed to navigate to new ${data.type}: ${e}`);
}
break;
}

default:
throw new Error("Unknown document type");
}

props.onComplete?.();
};

// Placeholder, not doing more than typechecking does for now but
// will eventually validate against json schema
const validateJson = (data: Document<string>) => {
if (!isImportableDocument(data)) {
return "Analysis and other document types cannot be imported at this time.";
}
return true;
};

return (
<div>
<JsonImport<"model" | "diagram"> onImport={handleImport} validate={validateJson} />
</div>
);
}
21 changes: 21 additions & 0 deletions packages/frontend/src/page/menubar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ import { useApi } from "../api";
import { Dialog, IconButton } from "../components";
import { createModel } from "../model/document";
import { TheoryLibraryContext } from "../stdlib";

import { Login } from "../user";
import { Import } from "./import";

import FilePlus from "lucide-solid/icons/file-plus";
import Info from "lucide-solid/icons/info";
import LogInIcon from "lucide-solid/icons/log-in";
import LogOutIcon from "lucide-solid/icons/log-out";
import MenuIcon from "lucide-solid/icons/menu";
import SettingsIcon from "lucide-solid/icons/settings";
import UploadIcon from "lucide-solid/icons/upload";

import "./menubar.css";

Expand Down Expand Up @@ -57,16 +60,20 @@ export function AppMenu(props: {
const auth = useAuth(getAuth(firebaseApp));

const [loginOpen, setLoginOpen] = createSignal(false);
const [openImport, setImportOpen] = createSignal(false);

// Root the dialog here so that it is not destroyed when the menu closes.
return (
<>
<HamburgerMenu>
<ImportMenuItem showImport={() => setImportOpen(true)} />
{props.children}
<Show when={props.children}>
<MenuSeparator />
</Show>

<HelpMenuItem />

<Show
when={auth.data}
fallback={<LogInMenuItem showLogin={() => setLoginOpen(true)} />}
Expand All @@ -78,6 +85,9 @@ export function AppMenu(props: {
<Dialog open={loginOpen()} onOpenChange={setLoginOpen} title="Log in">
<Login onComplete={() => setLoginOpen(false)} />
</Dialog>
<Dialog open={openImport()} onOpenChange={setImportOpen} title="Import">
<Import onComplete={() => setImportOpen(false)} />
</Dialog>
</>
);
}
Expand Down Expand Up @@ -154,3 +164,14 @@ function SettingsMenuItem() {
</MenuItem>
);
}

function ImportMenuItem(props: {
showImport: () => void;
}) {
return (
<MenuItem onSelect={props.showImport}>
<UploadIcon />
<MenuItemLabel>{"Import notebook"}</MenuItemLabel>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put this at the very top because I think it should be next to the "new" buttons which are handled by document type and otherwise I'd have to split up the child.props below.

</MenuItem>
);
}
Loading