From 45e133e255cdc984abe8d204b10d7cc8287a8d1c Mon Sep 17 00:00:00 2001 From: Tuurminator69 Date: Tue, 11 Nov 2025 16:05:25 +0100 Subject: [PATCH 1/7] feat: added temporary dummy button menu ref: N25B-189 --- package-lock.json | 30 +++++++++----- src/pages/VisProgPage/VisProg.module.css | 8 ++++ .../components/DragDropSidebar.tsx | 40 ++++++++++++++----- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1ed79f..852ac32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -691,6 +692,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -714,6 +716,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2405,7 +2408,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2416,7 +2418,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2430,7 +2431,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2445,8 +2445,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2533,8 +2532,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2710,6 +2708,7 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2720,6 +2719,7 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -2801,6 +2801,7 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -3389,6 +3390,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3669,6 +3671,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -4049,6 +4052,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4193,8 +4197,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -4324,6 +4327,7 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5275,6 +5279,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5926,6 +5931,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -6101,7 +6107,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6756,6 +6761,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7393,6 +7399,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7608,6 +7615,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7767,6 +7775,7 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7860,6 +7869,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index f2f90c7..5983702 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -40,6 +40,14 @@ align-items: center; } +.save-load-panel { + outline: 2.5pt solid black; + border-radius: 0 0 5pt 5pt; + border-color: dimgrey; + background-color: canvas; + align-items: center; +} + .dnd-node-container { background-color: canvas; justify-content: center; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 383f72c..af359a6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -131,18 +131,38 @@ export function DndToolbar() { ); return ( -
-
- You can drag these nodes to the pane to create new nodes. + + +
+
+
+ You can save and load your graph here. +
+
+ + Save Graph + + + Load Graph + +
-
- - phase Node - - - norm Node - + +
+
+ You can drag these nodes to the pane to create new nodes. +
+
+ + phase Node + + + norm Node + +
+ +
); } \ No newline at end of file From 3cbf983b4154bdacea6528a6f98f742eaafce2ec Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Tue, 11 Nov 2025 16:32:37 +0100 Subject: [PATCH 2/7] fix: save and load are now buttons Really small change so me and Arthur can work on this toegether at the same time feat: N25B-189 --- package-lock.json | 30 +++++++------------ .../components/DragDropSidebar.tsx | 8 ++--- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 852ac32..a1ed79f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,7 +96,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -692,7 +691,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -716,7 +714,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2408,6 +2405,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2418,6 +2416,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2431,6 +2430,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2445,7 +2445,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2532,7 +2533,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2708,7 +2710,6 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2719,7 +2720,6 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -2801,7 +2801,6 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -3390,7 +3389,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3671,7 +3669,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -4052,7 +4049,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4197,7 +4193,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -4327,7 +4324,6 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5279,7 +5275,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5931,7 +5926,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -6107,6 +6101,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6761,7 +6756,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7399,7 +7393,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7615,7 +7608,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7775,7 +7767,6 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7869,7 +7860,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index af359a6..e56501c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -139,12 +139,12 @@ export function DndToolbar() { You can save and load your graph here.
- + +
From 22da2ca6649d6a586b1d5d2c131e18ed946e5847 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Wed, 12 Nov 2025 11:17:15 +0100 Subject: [PATCH 3/7] feat: added functionality of saving and loadiing for supported browsers, using the File System Access API. otherwise, fallback to download the file and then you can load from download ref: N25B-189 --- src/pages/VisProgPage/VisProg.tsx | 4 + .../components/DragDropSidebar.tsx | 15 -- .../components/SaveLoadPanel.tsx | 136 ++++++++++++++++++ 3 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 8208a70..8be3696 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -20,6 +20,7 @@ import graphReducer from "./visualProgrammingUI/GraphReducer.ts"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' +import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx'; // --| config starting params for flow |-- @@ -100,6 +101,9 @@ const VisProgUI = () => { {/* contains the drag and drop panel for nodes */} + + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 6169b7b..91ba510 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -150,22 +150,7 @@ export function DndToolbar() { ); return ( - -
-
-
- You can save and load your graph here. -
-
- - -
-
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx new file mode 100644 index 0000000..ff0c041 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import useFlowStore from "../VisProgStores"; +import styles from "../../VisProg.module.css"; +import { useReactFlow, type Edge } from "@xyflow/react"; +import type { AppNode } from "../VisProgTypes"; + +type SavedProject = { + version: 1; + name: string; + savedAt: string; // ISO timestamp + nodes: AppNode[]; + edges: Edge[]; +}; + + + +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; + } +} + +export default function SaveLoadPanel() { + const nodes = useFlowStore((s) => s.nodes) as AppNode[]; + const edges = useFlowStore((s) => s.edges) as Edge[]; + const setNodes = useFlowStore((s) => s.setNodes); + const setEdges = useFlowStore((s) => s.setEdges); + + 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 onLoad = async () => { + try { + const proj = await loadWithPicker(); + 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 + setEdges([]); + setNodes([]); + + //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); + + } catch (e) { + console.error(e); + alert("Loading failed. See console."); + } + }; + + return ( +
+
You can save and load your graph here.
+
+ + +
+
+ ); +} From 221fbe42c2a6f60bc3c7d2d952097c24948faf43 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Wed, 12 Nov 2025 14:29:59 +0100 Subject: [PATCH 4/7] chore: added tests got 50.72% code coverage. Not sure if it is feasible to mock import behaviour ref: N25B-189 --- package.json | 3 +- .../components/SaveLoadPanel.tsx | 10 +- .../components/SaveLoadPanel.test.tsx | 172 ++++++++++++++++++ 3 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx diff --git a/package.json b/package.json index cb88357..983d37b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "jest" }, "dependencies": { "@neodrag/react": "^2.3.1", diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx index ff0c041..b4adc47 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -1,8 +1,8 @@ -import React from "react"; import useFlowStore from "../VisProgStores"; import styles from "../../VisProg.module.css"; -import { useReactFlow, type Edge } from "@xyflow/react"; +import {type Edge } from "@xyflow/react"; import type { AppNode } from "../VisProgTypes"; +import { cleanup } from "@testing-library/react"; type SavedProject = { version: 1; @@ -14,7 +14,7 @@ type SavedProject = { -function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { +export function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { const payload = { version: 1, name, @@ -109,9 +109,7 @@ export default function SaveLoadPanel() { } //We clear all the current edges and nodes - setEdges([]); - setNodes([]); - + cleanup(); //set all loaded nodes and edges into the VisProg const loadedNodes = proj.nodes as AppNode[]; const loadedEdges = proj.edges as Edge[]; 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..36fed6c --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -0,0 +1,172 @@ +import { mockReactFlow } from '../../../../setupFlowTests.ts'; +import { act, render, screen, fireEvent } 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'; + +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) +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(); + 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); + }); +} + +// 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); +} From 381fdaca1ac91cfb39e6c7fd4a49f93cbf7ca8f8 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:58:09 +0100 Subject: [PATCH 5/7] fix: re-render TextField when input changes from parent ref: N25B-189 --- src/components/TextField.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 58de55d..1d5ca6a 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,4 +1,4 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import styles from "./TextField.module.css"; /** @@ -86,6 +86,9 @@ export function TextField({ }) { const [inputValue, setInputValue] = useState(value); + // Re-render when the value gets updated externally + useEffect(() => setInputValue(value), [setInputValue, value]); + const onCommit = () => setValue(inputValue); return Date: Thu, 4 Dec 2025 09:12:01 +0100 Subject: [PATCH 6/7] chore: applied feedback from merge request Removed all the DOM manipulations and created a utils file so npx eslint is happy. Also changed the tests to test the new version of the code. ref: N25B-189 --- src/pages/VisProgPage/VisProg.module.css | 19 ++ .../components/SaveLoadPanel.tsx | 187 +++++------ src/utils/SaveLoad.ts | 19 ++ .../components/SaveLoadPanel.test.tsx | 298 ++++++++---------- 4 files changed, 261 insertions(+), 262 deletions(-) create mode 100644 src/utils/SaveLoad.ts 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(""); + }); + }); +}); From 1bfcfc04587b262931c7e4080d0a92760f341a72 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:55:36 +0100 Subject: [PATCH 7/7] feat: use input element directly Previously, a button proxy was used which required the use of complicated reference management. Using the HTML `input` element directly simplifies the implementation. Also moved some styles. ref: N25B-189 --- src/index.css | 1 - src/pages/VisProgPage/VisProg.module.css | 28 ----- .../components/SaveLoadPanel.module.css | 30 ++++++ .../components/SaveLoadPanel.tsx | 100 +++++------------- .../components/SaveLoadPanel.test.tsx | 16 +-- 5 files changed, 66 insertions(+), 109 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css 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 df38224..5f2aa78 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -26,14 +26,6 @@ align-items: center; } -.save-load-panel { - outline: 2.5pt solid black; - border-radius: 0 0 5pt 5pt; - border-color: dimgrey; - background-color: canvas; - align-items: center; -} - .dnd-node-container { background-color: canvas; justify-content: center; @@ -134,23 +126,3 @@ outline: red solid 2pt; 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.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 index 7e34147..baac724 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -1,7 +1,7 @@ -import { useRef, useState, useEffect } from "react"; +import {type ChangeEvent, useRef, useState} from "react"; import useFlowStore from "../VisProgStores"; -import styles from "../../VisProg.module.css"; -import { cleanup } from "@testing-library/react"; +import visProgStyles from "../../VisProg.module.css"; +import styles from "./SaveLoadPanel.module.css"; import { makeProjectBlob, type SavedProject } from "../../../../utils/SaveLoad"; export default function SaveLoadPanel() { @@ -14,99 +14,55 @@ export default function SaveLoadPanel() { // 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 () => { - const nameGuess = "visual-program"; + const onSave = async (nameGuess = "visual-program") => { const blob = makeProjectBlob(nameGuess, nodes, edges); const url = URL.createObjectURL(blob); setSaveUrl(url); }; - const onLoad = async () => { + // 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 proj = await new Promise((resolve) => { - resolverRef.current = resolve; - inputRef.current?.click(); - }); - // clear stored resolver - resolverRef.current = null; - - if (!proj) return; - - cleanup(); - setNodes(proj.nodes); - setEdges(proj.edges); + 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."); - } - }; - - // 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; + } 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/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx index 97bbf11..9d85323 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -106,22 +106,22 @@ describe("SaveLoadPanel - combined tests", () => { 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(); - // 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); + // Give some input + act(() => { fireEvent.change(input, { target: { files: [file] } }); - await Promise.resolve(); }); await waitFor(() => { + expect(window.alert).toHaveBeenCalledTimes(1); + const nodesAfter = useFlowStore.getState().nodes; expect(nodesAfter).toHaveLength(0); expect(input.value).toBe(""); @@ -134,7 +134,7 @@ describe("SaveLoadPanel - combined tests", () => { expect(input).toBeTruthy(); // Click Load to set resolver - const loadButton = screen.getByRole("button", { name: /load graph/i }); + const loadButton = screen.getByLabelText(/load graph/i); await act(async () => { fireEvent.click(loadButton);