diff --git a/src/index.css b/src/index.css index 986e666..6e28fe5 100644 --- a/src/index.css +++ b/src/index.css @@ -26,7 +26,6 @@ html, body, #root { } a { - font-weight: 500; color: canvastext; } diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 250fba6..5f2aa78 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -126,4 +126,3 @@ outline: red solid 2pt; filter: drop-shadow(0 0 0.25rem red); } - diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 81f95bb..0933d28 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -14,6 +14,7 @@ import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts'; +import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx'; // --| config starting params for flow |-- @@ -103,7 +104,10 @@ const VisProgUI = () => { > {/* contains the drag and drop panel for nodes */} - + + + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css new file mode 100644 index 0000000..9dbafa2 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css @@ -0,0 +1,30 @@ +.save-load-panel { + border-radius: 0 0 5pt 5pt; + background-color: canvas; +} + +label.file-input-button { + cursor: pointer; + outline: forestgreen solid 2pt; + filter: drop-shadow(0 0 0.25rem forestgreen); + transition: filter 200ms; + + input[type="file"] { + display: none; + } + + &:hover { + filter: drop-shadow(0 0 0.5rem forestgreen); + } +} + +.save-button { + text-decoration: none; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); + transition: filter 200ms; + + &:hover { + filter: drop-shadow(0 0 0.5rem dodgerblue); + } +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx new file mode 100644 index 0000000..baac724 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -0,0 +1,69 @@ +import {type ChangeEvent, useRef, useState} from "react"; +import useFlowStore from "../VisProgStores"; +import visProgStyles from "../../VisProg.module.css"; +import styles from "./SaveLoadPanel.module.css"; +import { makeProjectBlob, type SavedProject } from "../../../../utils/SaveLoad"; + +export default function SaveLoadPanel() { + const nodes = useFlowStore((s) => s.nodes); + const edges = useFlowStore((s) => s.edges); + const setNodes = useFlowStore((s) => s.setNodes); + const setEdges = useFlowStore((s) => s.setEdges); + + const [saveUrl, setSaveUrl] = useState(null); + + // ref to the file input + const inputRef = useRef(null); + + const onSave = async (nameGuess = "visual-program") => { + const blob = makeProjectBlob(nameGuess, nodes, edges); + const url = URL.createObjectURL(blob); + setSaveUrl(url); + }; + + // input change handler updates the graph with a parsed JSON file + const handleFileChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const parsed = JSON.parse(text) as SavedProject; + if (!parsed.nodes || !parsed.edges) throw new Error("Invalid file format"); + setNodes(parsed.nodes); + setEdges(parsed.edges); + } catch (e) { + console.error(e); + alert("Loading failed. See console."); + } finally { + // allow re-selecting same file next time + if (inputRef.current) inputRef.current.value = ""; + } + }; + + const defaultName = "visual-program"; + return ( +
+
You can save and load your graph here.
+ +
+ ); +} diff --git a/src/utils/SaveLoad.ts b/src/utils/SaveLoad.ts new file mode 100644 index 0000000..4ea9666 --- /dev/null +++ b/src/utils/SaveLoad.ts @@ -0,0 +1,19 @@ +import {type Edge, type Node } from "@xyflow/react"; + +export type SavedProject = { + name: string; + savedASavedProject: string; // ISO timestamp + nodes: Node[]; + edges: Edge[]; +}; + +// Creates a JSON Blob containing the current visual program (nodes + edges) +export function makeProjectBlob(name: string, nodes: Node[], edges: Edge[]): Blob { + const payload = { + name, + savedAt: new Date().toISOString(), + nodes, + edges, + }; + return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); +} \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx new file mode 100644 index 0000000..9d85323 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -0,0 +1,154 @@ +// SaveLoadPanel.all.test.tsx +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import SaveLoadPanel from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx'; +import { makeProjectBlob } from '../../../../../src/utils/SaveLoad.ts'; +import { mockReactFlow } from "../../../../setupFlowTests.ts"; // optional helper if present + +// helper to read Blob contents in tests (works in Node/Jest env) +async function blobToText(blob: Blob): Promise { + if (typeof (blob as any).text === "function") return await (blob as any).text(); + if (typeof (blob as any).arrayBuffer === "function") { + const buf = await (blob as any).arrayBuffer(); + return new TextDecoder().decode(buf); + } + return await new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(String(fr.result)); + fr.onerror = () => reject(fr.error); + fr.readAsText(blob); + }); +} + +beforeAll(() => { + // if you have a mockReactFlow helper used in other tests, call it + if (typeof mockReactFlow === "function") mockReactFlow(); +}); + +beforeEach(() => { + // clear and seed the zustand store to a known empty state + act(() => { + const { setNodes, setEdges } = useFlowStore.getState(); + setNodes([]); + setEdges([]); + }); + + // Ensure URL.createObjectURL exists so jest.spyOn works + if (!URL.createObjectURL) URL.createObjectURL = jest.fn(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("SaveLoadPanel - combined tests", () => { + test("makeProjectBlob creates a valid JSON blob", async () => { + const nodes = [ + { + id: "n1", + type: "start", + position: { x: 0, y: 0 }, + data: { label: "Start" }, + } as any, + ]; + const edges: any[] = []; + + const blob = makeProjectBlob("my-project", nodes, edges); + expect(blob).toBeInstanceOf(Blob); + + const text = await blobToText(blob); + const parsed = JSON.parse(text); + + expect(parsed.name).toBe("my-project"); + expect(typeof parsed.savedAt).toBe("string"); + expect(Array.isArray(parsed.nodes)).toBe(true); + expect(Array.isArray(parsed.edges)).toBe(true); + expect(parsed.nodes).toEqual(nodes); + expect(parsed.edges).toEqual(edges); + }); + + test("onSave creates a blob URL and sets anchor href", async () => { + // Seed the store so onSave has nodes to save + act(() => { + useFlowStore.getState().setNodes([ + { id: "start", type: "start", position: { x: 0, y: 0 }, data: { label: "start" } } as any, + ]); + useFlowStore.getState().setEdges([]); + }); + + // Ensure createObjectURL exists and spy it + if (!URL.createObjectURL) URL.createObjectURL = jest.fn(); + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-url"); + + render(); + + const saveAnchor = screen.getByText(/Save Graph/i) as HTMLAnchorElement; + + await act(async () => { + fireEvent.click(saveAnchor); + }); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + const blobArg = createObjectURLSpy.mock.calls[0][0]; + expect(blobArg).toBeInstanceOf(Blob); + + expect(saveAnchor.getAttribute("href")).toBe("blob:fake-url"); + + const text = await blobToText(blobArg as Blob); + const parsed = JSON.parse(text); + + expect(parsed.name).toBeDefined(); + expect(parsed.nodes).toBeDefined(); + expect(parsed.edges).toBeDefined(); + + createObjectURLSpy.mockRestore(); + }); + + test("onLoad with invalid JSON does not update store", async () => { + const file = new File(["not json"], "bad.json", { type: "application/json" }); + file.text = jest.fn(() => Promise.resolve(`{"bad json`)); + + window.alert = jest.fn(); + + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input).toBeTruthy(); + + // Give some input + act(() => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledTimes(1); + + const nodesAfter = useFlowStore.getState().nodes; + expect(nodesAfter).toHaveLength(0); + expect(input.value).toBe(""); + }); + }); + + test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input).toBeTruthy(); + + // Click Load to set resolver + const loadButton = screen.getByLabelText(/load graph/i); + + await act(async () => { + fireEvent.click(loadButton); + // simulate user cancelling: change with empty files + fireEvent.change(input, { target: { files: [] } }); + await Promise.resolve(); + }); + + await waitFor(() => { + const nodesAfter = useFlowStore.getState().nodes; + const edgesAfter = useFlowStore.getState().edges; + expect(nodesAfter).toHaveLength(0); + expect(edgesAfter).toHaveLength(0); + expect(input.value).toBe(""); + }); + }); +});