diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 0ad6bf2..df38224 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -135,3 +135,22 @@ filter: drop-shadow(0 0 0.25rem red); } +.save-button-like { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); +} + +a.save-button-like { + display: inline-block; + text-decoration: none; + color: inherit; + cursor: pointer; + transition: filter 200ms, background-color 200ms; +} + +a.save-button-like:hover { + filter: drop-shadow(0 0 0.5rem dodgerblue); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx index b4adc47..7e34147 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -1,133 +1,112 @@ +import { useRef, useState, useEffect } from "react"; import useFlowStore from "../VisProgStores"; import styles from "../../VisProg.module.css"; -import {type Edge } from "@xyflow/react"; -import type { AppNode } from "../VisProgTypes"; import { cleanup } from "@testing-library/react"; - -type SavedProject = { - version: 1; - name: string; - savedAt: string; // ISO timestamp - nodes: AppNode[]; - edges: Edge[]; -}; - - - -export function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { - const payload = { - version: 1, - name, - savedAt: new Date().toISOString(), - nodes, - edges, - }; - return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); -} - -async function saveWithPicker(defaultName: string, blob: Blob) { - // @ts-expect-error: not in lib.dom.d.ts everywhere - if (window.showSaveFilePicker) { - // @ts-expect-error - const handle = await window.showSaveFilePicker({ - suggestedName: `${defaultName}.visprog.json`, - types: [{ description: "Visual Program Project", accept: { "application/json": [".visprog.json", ".json"] } }], - }); - const writable = await handle.createWritable(); - await writable.write(blob); - await writable.close(); - return; - } - // Fallback if File system API is not supported - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${defaultName}.visprog.json`; - a.click(); - URL.revokeObjectURL(url); -} - -async function loadWithPicker(): Promise { - try { - // @ts-expect-error - if (window.showOpenFilePicker) { - // @ts-expect-error - const [handle] = await window.showOpenFilePicker({ - multiple: false, - types: [{ description: "Visual Program Project", accept: { "application/json": [".visprog.json", ".json", ".txt"] } }], - }); - const file = await handle.getFile(); - return JSON.parse(await file.text()) as SavedProject; - } - // Fallback: input - return await new Promise((resolve) => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".visprog.json,.json,.txt,application/json,text/plain"; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) return resolve(null); - try { - resolve(JSON.parse(await file.text()) as SavedProject); - } catch { - resolve(null); - } - }; - input.click(); - }); - } catch { - return null; - } -} +import { makeProjectBlob, type SavedProject } from "../../../../utils/SaveLoad"; export default function SaveLoadPanel() { - const nodes = useFlowStore((s) => s.nodes) as AppNode[]; - const edges = useFlowStore((s) => s.edges) as Edge[]; + 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); + // ref to hold the resolver for the currently pending load promise + const resolverRef = useRef<((p: SavedProject | null) => void) | null>(null); + + useEffect(() => { + return () => { + if (resolverRef.current) { + resolverRef.current(null); + resolverRef.current = null; + } + }; + }, []); + const onSave = async () => { - try { - const nameGuess = - (nodes.find((n) => n.type === "start")?.data?.label as string) || "visual-program"; - const blob = makeProjectBlob(nameGuess, nodes, edges); - await saveWithPicker(nameGuess, blob); - } catch (e) { - console.error(e); - alert("Saving failed. See console."); - } + const nameGuess = "visual-program"; + const blob = makeProjectBlob(nameGuess, nodes, edges); + const url = URL.createObjectURL(blob); + setSaveUrl(url); }; const onLoad = async () => { try { - const proj = await loadWithPicker(); + const proj = await new Promise((resolve) => { + resolverRef.current = resolve; + inputRef.current?.click(); + }); + // clear stored resolver + resolverRef.current = null; + if (!proj) return; - if (proj.version !== 1 || !Array.isArray(proj.nodes) || !Array.isArray(proj.edges)) { - alert("Invalid project file format."); - return; - } - - //We clear all the current edges and nodes cleanup(); - //set all loaded nodes and edges into the VisProg - const loadedNodes = proj.nodes as AppNode[]; - const loadedEdges = proj.edges as Edge[]; - setNodes(loadedNodes); - setEdges(loadedEdges); - + setNodes(proj.nodes); + setEdges(proj.edges); } catch (e) { console.error(e); alert("Loading failed. See console."); } }; + // input change handler resolves the onLoad promise with parsed project or null + const handleFileChange = async (e: React.ChangeEvent) => { + try { + const file = e.target.files?.[0]; + if (!file) { + resolverRef.current?.(null); + resolverRef.current = null; + return; + } + try { + const text = await file.text(); + const parsed = JSON.parse(text) as SavedProject; + resolverRef.current?.(parsed ?? null); + } catch { + resolverRef.current?.(null); + } finally { + // allow re-selecting same file next time + if (inputRef.current) inputRef.current.value = ""; + resolverRef.current = null; + } + } catch { + resolverRef.current?.(null); + resolverRef.current = null; + } + }; + + 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 index 36fed6c..97bbf11 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -1,153 +1,15 @@ -import { mockReactFlow } from '../../../../setupFlowTests.ts'; -import { act, render, screen, fireEvent } from '@testing-library/react'; +// SaveLoadPanel.all.test.tsx +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; -import { addNode } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx'; -import { makeProjectBlob } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.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 -beforeAll(() => { - mockReactFlow(); -}); - -beforeEach(() => { - const { setNodes, setEdges } = useFlowStore.getState(); - act(() => { - setNodes([]); - setEdges([]); - }); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -describe('Load and save panel', () => { - test('save and load functions work correctly', async () => { - // create two nodes via your sidebar API - act(() => { - addNode('phase', { x: 100, y: 100 }); - addNode('norm', { x: 200, y: 200 }); - }); - - const initialState = useFlowStore.getState(); - expect(initialState.nodes.length).toBe(2); - - // make blob from current nodes/edges - const blob = makeProjectBlob('test-project', initialState.nodes, initialState.edges); - - // simulate loading from that blob - const parsed = JSON.parse(await blobToText(blob)); - - act(() => { - const { setNodes, setEdges } = useFlowStore.getState(); - setEdges([]); // clear edges first (mirrors app behavior) - setNodes(parsed.nodes); - setEdges(parsed.edges); - }); - - const loadedState = useFlowStore.getState(); - expect(loadedState.nodes.length).toBe(2); - expect(loadedState.nodes).toEqual(initialState.nodes); - expect(loadedState.edges).toEqual(initialState.edges); - }); - - test('Save uses showSaveFilePicker and writes JSON', async () => { - // Seed a simple graph so Save has something to write - act(() => { - useFlowStore.getState().setNodes([ - { id: 'start', type: 'start', position: { x: 0, y: 0 }, data: { label: 'start' } } as any, - { id: 'phase-1', type: 'phase', position: { x: 100, y: 120 }, data: { label: 'P1', number: 1 } } as any, - { id: 'end', type: 'end', position: { x: 0, y: 300 }, data: { label: 'End' } } as any, - ]); - useFlowStore.getState().setEdges([ - { id: 'start-phase-1', source: 'start', target: 'phase-1' } as any, - ]); - }); - - // capture what the app writes; don't decode inside the spy - let writtenChunk: any = null; - const write = jest.fn(async (chunk: any) => { writtenChunk = chunk; }); - const close = jest.fn().mockResolvedValue(undefined); - const createWritable = jest.fn().mockResolvedValue({ write, close }); - - // Mock the picker - (window as any).showSaveFilePicker = jest.fn().mockResolvedValue({ createWritable }); - - render(); - - await act(async () => { - fireEvent.click(screen.getByText(/Save Graph/i)); - }); - // @ts-expect-error - expect(window.showSaveFilePicker).toHaveBeenCalledTimes(1); - expect(createWritable).toHaveBeenCalledTimes(1); - expect(write).toHaveBeenCalledTimes(1); - expect(close).toHaveBeenCalledTimes(1); - - const writtenText = await chunkToString(writtenChunk); - const json = JSON.parse(writtenText); - expect(json.version).toBe(1); - expect(json.name).toBeDefined(); - expect(Array.isArray(json.nodes)).toBe(true); - expect(Array.isArray(json.edges)).toBe(true); - expect(json.behaviorProgram).toBeUndefined(); - }); - - test('Save falls back to anchor download when picker unavailable', async () => { - // Remove picker so we hit the fallback - delete (window as any).showSaveFilePicker; - - // Keep a reference to the REAL createElement to avoid recursion - const realCreateElement = document.createElement.bind(document); - - // Spy on URL + anchor click - const origCreateObjectURL = URL.createObjectURL; - const origRevokeObjectURL = URL.revokeObjectURL; - (URL as any).createObjectURL = jest.fn(() => 'blob:fake-url'); - (URL as any).revokeObjectURL = jest.fn(); - - const clickSpy = jest.fn(); - const createElementSpy = jest - .spyOn(document, 'createElement') - .mockImplementation((tag: any, opts?: any) => { - if (tag === 'a') { - // return a minimal anchor with a click spy - return { - set href(_v: string) {}, - set download(_v: string) {}, - click: clickSpy, - } as unknown as HTMLAnchorElement; - } - // call the REAL createElement for everything else - return realCreateElement(tag, opts as any); - }); - - render(); - - await act(async () => { - fireEvent.click(screen.getByText(/Save Graph/i)); - }); - - expect(URL.createObjectURL).toHaveBeenCalledTimes(1); - expect(clickSpy).toHaveBeenCalledTimes(1); - - // cleanup - createElementSpy.mockRestore(); - (URL as any).createObjectURL = origCreateObjectURL; - (URL as any).revokeObjectURL = origRevokeObjectURL; - }); -}); - -// -// helpers -// - -// portable blob reader (no Response needed) +// helper to read Blob contents in tests (works in Node/Jest env) async function blobToText(blob: Blob): Promise { - const anyBlob = blob as any; - if (typeof anyBlob.text === 'function') return anyBlob.text(); - if (typeof anyBlob.arrayBuffer === 'function') { - const buf = await anyBlob.arrayBuffer(); + 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) => { @@ -158,15 +20,135 @@ async function blobToText(blob: Blob): Promise { }); } -// normalize whatever chunk createWritable.write receives to a string -async function chunkToString(chunk: any): Promise { - if (typeof chunk === 'string') return chunk; - if (chunk instanceof Blob) return blobToText(chunk); - if (chunk?.buffer instanceof ArrayBuffer) { - return new TextDecoder().decode(chunk as Uint8Array); - } - if (chunk instanceof ArrayBuffer) { - return new TextDecoder().decode(new Uint8Array(chunk)); - } - return String(chunk); -} +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" }); + + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input).toBeTruthy(); + + // Click Load to install the resolver + const loadButton = screen.getByRole("button", { name: /load graph/i }); + + // Do click and change inside same act to ensure resolver is set + await act(async () => { + fireEvent.click(loadButton); + fireEvent.change(input, { target: { files: [file] } }); + await Promise.resolve(); + }); + + await waitFor(() => { + 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.getByRole("button", { name: /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(""); + }); + }); +});