From 4da06c78216061deec993a7c8eec6413fc6e1558 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 Aug 2025 10:05:22 +0200 Subject: [PATCH 01/13] refactor: Bump inquirer from 12.6.3 to 12.9.0 (#2959) --- package-lock.json | 176 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 89 insertions(+), 89 deletions(-) diff --git a/package-lock.json b/package-lock.json index c4648074d..6d6ecf383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "graphql": "16.11.0", "immutable": "5.1.3", "immutable-devtools": "0.1.5", - "inquirer": "12.6.3", + "inquirer": "12.9.0", "js-beautify": "1.15.4", "node-fetch": "3.3.2", "otpauth": "8.0.3", @@ -2691,14 +2691,14 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.8.tgz", - "integrity": "sha512-d/QAsnwuHX2OPolxvYcgSj7A9DO9H6gVOy2DvBTx+P2LH2iRTo/RSGV3iwCzW024nP9hw98KIuDmdyhZQj1UQg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.0.tgz", + "integrity": "sha512-fdSw07FLJEU5vbpOPzXo5c6xmMGDzbZE2+niuDHX5N6mc6V0Ebso/q3xiHra4D73+PMsC8MJmcaZKuAAoaQsSA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/figures": "^1.0.12", - "@inquirer/type": "^3.0.7", + "@inquirer/core": "^10.1.15", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -2715,13 +2715,13 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.12", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.12.tgz", - "integrity": "sha512-dpq+ielV9/bqgXRUbNH//KsY6WEw9DrGPmipkpmgC1Y46cwuBTNx7PXFWTjc3MQ+urcc0QxoVHcMI0FW4Ok0hg==", + "version": "5.1.14", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz", + "integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/type": "^3.0.7" + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -2736,13 +2736,13 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.13", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.13.tgz", - "integrity": "sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==", + "version": "10.1.15", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.15.tgz", + "integrity": "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==", "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.12", - "@inquirer/type": "^3.0.7", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", @@ -2789,13 +2789,13 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.13.tgz", - "integrity": "sha512-WbicD9SUQt/K8O5Vyk9iC2ojq5RHoCLK6itpp2fHsWe44VxxcA9z3GTWlvjSTGmMQpZr+lbVmrxdHcumJoLbMA==", + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.15.tgz", + "integrity": "sha512-wst31XT8DnGOSS4nNJDIklGKnf+8shuauVrWzgKegWUe28zfCftcWZ2vktGdzJgcylWSS2SrDnYUb6alZcwnCQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/type": "^3.0.7", + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8", "external-editor": "^3.1.0" }, "engines": { @@ -2811,13 +2811,13 @@ } }, "node_modules/@inquirer/expand": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.15.tgz", - "integrity": "sha512-4Y+pbr/U9Qcvf+N/goHzPEXiHH8680lM3Dr3Y9h9FFw4gHS+zVpbj8LfbKWIb/jayIB4aSO4pWiBTrBYWkvi5A==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.17.tgz", + "integrity": "sha512-PSqy9VmJx/VbE3CT453yOfNa+PykpKg/0SYP7odez1/NWBGuDXgPhp4AeGYYKjhLn5lUUavVS/JbeYMPdH50Mw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/type": "^3.0.7", + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -2833,22 +2833,22 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz", - "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@inquirer/input": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.12.tgz", - "integrity": "sha512-xJ6PFZpDjC+tC1P8ImGprgcsrzQRsUh9aH3IZixm1lAZFK49UGHxM3ltFfuInN2kPYNfyoPRh+tU4ftsjPLKqQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.1.tgz", + "integrity": "sha512-tVC+O1rBl0lJpoUZv4xY+WGWY8V5b0zxU1XDsMsIHYregdh7bN5X5QnIONNBAl0K765FYlAfNHS2Bhn7SSOVow==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/type": "^3.0.7" + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -2863,13 +2863,13 @@ } }, "node_modules/@inquirer/number": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.15.tgz", - "integrity": "sha512-xWg+iYfqdhRiM55MvqiTCleHzszpoigUpN5+t1OMcRkJrUrw7va3AzXaxvS+Ak7Gny0j2mFSTv2JJj8sMtbV2g==", + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.17.tgz", + "integrity": "sha512-GcvGHkyIgfZgVnnimURdOueMk0CztycfC8NZTiIY9arIAkeOgt6zG57G+7vC59Jns3UX27LMkPKnKWAOF5xEYg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/type": "^3.0.7" + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -2884,13 +2884,13 @@ } }, "node_modules/@inquirer/password": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.15.tgz", - "integrity": "sha512-75CT2p43DGEnfGTaqFpbDC2p2EEMrq0S+IRrf9iJvYreMy5mAWj087+mdKyLHapUEPLjN10mNvABpGbk8Wdraw==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.17.tgz", + "integrity": "sha512-DJolTnNeZ00E1+1TW+8614F7rOJJCM4y4BAGQ3Gq6kQIG+OJ4zr3GLjIjVVJCbKsk2jmkmv6v2kQuN/vriHdZA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/type": "^3.0.7", + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2" }, "engines": { @@ -2906,21 +2906,21 @@ } }, "node_modules/@inquirer/prompts": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.5.3.tgz", - "integrity": "sha512-8YL0WiV7J86hVAxrh3fE5mDCzcTDe1670unmJRz6ArDgN+DBK1a0+rbnNWp4DUB5rPMwqD5ZP6YHl9KK1mbZRg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.0.tgz", + "integrity": "sha512-JHwGbQ6wjf1dxxnalDYpZwZxUEosT+6CPGD9Zh4sm9WXdtUp9XODCQD3NjSTmu+0OAyxWXNOqf0spjIymJa2Tw==", "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^4.1.8", - "@inquirer/confirm": "^5.1.12", - "@inquirer/editor": "^4.2.13", - "@inquirer/expand": "^4.0.15", - "@inquirer/input": "^4.1.12", - "@inquirer/number": "^3.0.15", - "@inquirer/password": "^4.0.15", - "@inquirer/rawlist": "^4.1.3", - "@inquirer/search": "^3.0.15", - "@inquirer/select": "^4.2.3" + "@inquirer/checkbox": "^4.2.0", + "@inquirer/confirm": "^5.1.14", + "@inquirer/editor": "^4.2.15", + "@inquirer/expand": "^4.0.17", + "@inquirer/input": "^4.2.1", + "@inquirer/number": "^3.0.17", + "@inquirer/password": "^4.0.17", + "@inquirer/rawlist": "^4.1.5", + "@inquirer/search": "^3.1.0", + "@inquirer/select": "^4.3.1" }, "engines": { "node": ">=18" @@ -2935,13 +2935,13 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.3.tgz", - "integrity": "sha512-7XrV//6kwYumNDSsvJIPeAqa8+p7GJh7H5kRuxirct2cgOcSWwwNGoXDRgpNFbY/MG2vQ4ccIWCi8+IXXyFMZA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.5.tgz", + "integrity": "sha512-R5qMyGJqtDdi4Ht521iAkNqyB6p2UPuZUbMifakg1sWtu24gc2Z8CJuw8rP081OckNDMgtDCuLe42Q2Kr3BolA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/type": "^3.0.7", + "@inquirer/core": "^10.1.15", + "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -2957,14 +2957,14 @@ } }, "node_modules/@inquirer/search": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.15.tgz", - "integrity": "sha512-YBMwPxYBrADqyvP4nNItpwkBnGGglAvCLVW8u4pRmmvOsHUtCAUIMbUrLX5B3tFL1/WsLGdQ2HNzkqswMs5Uaw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.1.0.tgz", + "integrity": "sha512-PMk1+O/WBcYJDq2H7foV0aAZSmDdkzZB9Mw2v/DmONRJopwA/128cS9M/TXWLKKdEQKZnKwBzqu2G4x/2Nqx8Q==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/figures": "^1.0.12", - "@inquirer/type": "^3.0.7", + "@inquirer/core": "^10.1.15", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -2980,14 +2980,14 @@ } }, "node_modules/@inquirer/select": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.2.3.tgz", - "integrity": "sha512-OAGhXU0Cvh0PhLz9xTF/kx6g6x+sP+PcyTiLvCrewI99P3BBeexD+VbuwkNDvqGkk3y2h5ZiWLeRP7BFlhkUDg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.3.1.tgz", + "integrity": "sha512-Gfl/5sqOF5vS/LIrSndFgOh7jgoe0UXEizDqahFRkq5aJBLegZ6WjuMh/hVEJwlFQjyLq1z9fRtvUMkb7jM1LA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/figures": "^1.0.12", - "@inquirer/type": "^3.0.7", + "@inquirer/core": "^10.1.15", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -3004,9 +3004,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.7.tgz", - "integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", "license": "MIT", "engines": { "node": ">=18" @@ -13566,17 +13566,17 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "node_modules/inquirer": { - "version": "12.6.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.6.3.tgz", - "integrity": "sha512-eX9beYAjr1MqYsIjx1vAheXsRk1jbZRvHLcBu5nA9wX0rXR1IfCZLnVLp4Ym4mrhqmh7AuANwcdtgQ291fZDfQ==", + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.0.tgz", + "integrity": "sha512-LlFVmvWVCun7uEgPB3vups9NzBrjJn48kRNtFGw3xU1H5UXExTEz/oF1JGLaB0fvlkUB+W6JfgLcSEaSdH7RPA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/prompts": "^7.5.3", - "@inquirer/type": "^3.0.7", + "@inquirer/core": "^10.1.15", + "@inquirer/prompts": "^7.8.0", + "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "mute-stream": "^2.0.0", - "run-async": "^3.0.0", + "run-async": "^4.0.5", "rxjs": "^7.8.2" }, "engines": { @@ -21913,9 +21913,9 @@ "license": "MIT" }, "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.5.tgz", + "integrity": "sha512-oN9GTgxUNDBumHTTDmQ8dep6VIJbgj9S3dPP+9XylVLIK4xB9XTXtKWROd5pnhdXR9k0EgO1JRcNh0T+Ny2FsA==", "license": "MIT", "engines": { "node": ">=0.12.0" diff --git a/package.json b/package.json index 99b357c99..95f93da9e 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "graphql": "16.11.0", "immutable": "5.1.3", "immutable-devtools": "0.1.5", - "inquirer": "12.6.3", + "inquirer": "12.9.0", "js-beautify": "1.15.4", "node-fetch": "3.3.2", "otpauth": "8.0.3", From 666e07860ba1478aec3cde9db5c98a5772ea07fb Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 3 Aug 2025 14:04:55 +0200 Subject: [PATCH 02/13] feat: Add App Settings option to store dashboard settings on server (#2958) --- src/dashboard/Data/Views/Views.react.js | 131 +++++--- .../DashboardSettings.react.js | 134 ++++++++ src/lib/ParseApp.js | 2 + src/lib/ServerConfigStorage.js | 199 ++++++++++++ src/lib/StoragePreferences.js | 83 +++++ src/lib/ViewPreferencesManager.js | 307 ++++++++++++++++++ 6 files changed, 810 insertions(+), 46 deletions(-) create mode 100644 src/lib/ServerConfigStorage.js create mode 100644 src/lib/StoragePreferences.js create mode 100644 src/lib/ViewPreferencesManager.js diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js index 1f7ddcd2d..88f0b98a2 100644 --- a/src/dashboard/Data/Views/Views.react.js +++ b/src/dashboard/Data/Views/Views.react.js @@ -14,6 +14,7 @@ import Notification from 'dashboard/Data/Browser/Notification.react'; import TableView from 'dashboard/TableView.react'; import tableStyles from 'dashboard/TableView.scss'; import * as ViewPreferences from 'lib/ViewPreferences'; +import ViewPreferencesManager from 'lib/ViewPreferencesManager'; import generatePath from 'lib/generatePath'; import stringCompare from 'lib/stringCompare'; import { ActionTypes as SchemaActionTypes } from 'lib/stores/SchemaStore'; @@ -37,6 +38,7 @@ class Views extends TableView { this.section = 'Core'; this.subsection = 'Views'; this._isMounted = false; + this.viewPreferencesManager = null; // Will be initialized when context is available this.state = { views: [], counts: {}, @@ -83,49 +85,65 @@ class Views extends TableView { } } - loadViews(app) { - const views = ViewPreferences.getViews(app.applicationId); - this.setState({ views, counts: {} }, () => { - views.forEach(view => { - if (view.showCounter) { - if (view.cloudFunction) { - // For Cloud Function views, call the function to get count - Parse.Cloud.run(view.cloudFunction, {}, { useMasterKey: true }) - .then(res => { - if (this._isMounted) { - this.setState(({ counts }) => ({ - counts: { ...counts, [view.name]: Array.isArray(res) ? res.length : 0 }, - })); - } - }) - .catch(error => { - if (this._isMounted) { - this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true); - } - }); - } else if (view.query && Array.isArray(view.query)) { - // For aggregation pipeline views, use existing logic - new Parse.Query(view.className) - .aggregate(view.query, { useMasterKey: true }) - .then(res => { - if (this._isMounted) { - this.setState(({ counts }) => ({ - counts: { ...counts, [view.name]: res.length }, - })); - } - }) - .catch(error => { - if (this._isMounted) { - this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true); - } - }); + async loadViews(app) { + // Initialize ViewPreferencesManager if not already done or if app changed + if (!this.viewPreferencesManager || this.viewPreferencesManager.app !== app) { + this.viewPreferencesManager = new ViewPreferencesManager(app); + } + + try { + const views = await this.viewPreferencesManager.getViews(app.applicationId); + this.setState({ views, counts: {} }, () => { + views.forEach(view => { + if (view.showCounter) { + if (view.cloudFunction) { + // For Cloud Function views, call the function to get count + Parse.Cloud.run(view.cloudFunction, {}, { useMasterKey: true }) + .then(res => { + if (this._isMounted) { + this.setState(({ counts }) => ({ + counts: { ...counts, [view.name]: Array.isArray(res) ? res.length : 0 }, + })); + } + }) + .catch(error => { + if (this._isMounted) { + this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true); + } + }); + } else if (view.query && Array.isArray(view.query)) { + // For aggregation pipeline views, use existing logic + new Parse.Query(view.className) + .aggregate(view.query, { useMasterKey: true }) + .then(res => { + if (this._isMounted) { + this.setState(({ counts }) => ({ + counts: { ...counts, [view.name]: res.length }, + })); + } + }) + .catch(error => { + if (this._isMounted) { + this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true); + } + }); + } } + }); + if (this._isMounted) { + this.loadData(this.props.params.name); } }); - if (this._isMounted) { - this.loadData(this.props.params.name); - } - }); + } catch (error) { + console.error('Failed to load views from server, falling back to local storage:', error); + // Fallback to local storage + const views = ViewPreferences.getViews(app.applicationId); + this.setState({ views, counts: {} }, () => { + if (this._isMounted) { + this.loadData(this.props.params.name); + } + }); + } } loadData(name) { @@ -671,8 +689,15 @@ class Views extends TableView { onConfirm={view => { this.setState( state => ({ showCreate: false, views: [...state.views, view] }), - () => { - ViewPreferences.saveViews(this.context.applicationId, this.state.views); + async () => { + if (this.viewPreferencesManager) { + try { + await this.viewPreferencesManager.saveViews(this.context.applicationId, this.state.views); + } catch (error) { + console.error('Failed to save views:', error); + this.showNote('Failed to save view changes', true); + } + } this.loadViews(this.context); } ); @@ -699,8 +724,15 @@ class Views extends TableView { newViews[state.editIndex] = view; return { editView: null, editIndex: null, views: newViews }; }, - () => { - ViewPreferences.saveViews(this.context.applicationId, this.state.views); + async () => { + if (this.viewPreferencesManager) { + try { + await this.viewPreferencesManager.saveViews(this.context.applicationId, this.state.views); + } catch (error) { + console.error('Failed to save views:', error); + this.showNote('Failed to save view changes', true); + } + } this.loadViews(this.context); } ); @@ -719,8 +751,15 @@ class Views extends TableView { const newViews = state.views.filter((_, i) => i !== state.deleteIndex); return { deleteIndex: null, views: newViews }; }, - () => { - ViewPreferences.saveViews(this.context.applicationId, this.state.views); + async () => { + if (this.viewPreferencesManager) { + try { + await this.viewPreferencesManager.saveViews(this.context.applicationId, this.state.views); + } catch (error) { + console.error('Failed to save views:', error); + this.showNote('Failed to save view changes', true); + } + } if (this.props.params.name === name) { const path = generatePath(this.context, 'views'); this.props.navigate(path); diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js index 25b657b1e..4e2cd7b0f 100644 --- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js +++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js @@ -17,6 +17,7 @@ import CodeSnippet from 'components/CodeSnippet/CodeSnippet.react'; import Notification from 'dashboard/Data/Browser/Notification.react'; import * as ColumnPreferences from 'lib/ColumnPreferences'; import * as ClassPreferences from 'lib/ClassPreferences'; +import ViewPreferencesManager from 'lib/ViewPreferencesManager'; import bcrypt from 'bcryptjs'; import * as OTPAuth from 'otpauth'; import QRCode from 'qrcode'; @@ -26,6 +27,7 @@ export default class DashboardSettings extends DashboardView { super(); this.section = 'App Settings'; this.subsection = 'Dashboard Configuration'; + this.viewPreferencesManager = null; this.state = { createUserInput: false, @@ -39,6 +41,8 @@ export default class DashboardSettings extends DashboardView { message: null, passwordInput: '', passwordHidden: true, + migrationLoading: false, + storagePreference: 'local', // Will be updated in componentDidMount copyData: { data: '', show: false, @@ -52,6 +56,81 @@ export default class DashboardSettings extends DashboardView { }; } + componentDidMount() { + this.initializeViewPreferencesManager(); + } + + initializeViewPreferencesManager() { + if (this.context) { + this.viewPreferencesManager = new ViewPreferencesManager(this.context); + this.loadStoragePreference(); + } + } + + loadStoragePreference() { + if (this.viewPreferencesManager) { + const preference = this.viewPreferencesManager.getStoragePreference(this.context.applicationId); + this.setState({ storagePreference: preference }); + } + } + + handleStoragePreferenceChange(preference) { + if (this.viewPreferencesManager) { + this.viewPreferencesManager.setStoragePreference(this.context.applicationId, preference); + this.setState({ storagePreference: preference }); + + // Show a notification about the change + this.showNote(`Storage preference changed to ${preference === 'server' ? 'server' : 'browser'}`); + } + } + + async migrateToServer() { + if (!this.viewPreferencesManager) { + this.showNote('ViewPreferencesManager not initialized'); + return; + } + + if (!this.viewPreferencesManager.isServerConfigEnabled()) { + this.showNote('Server configuration is not enabled for this app. Please add a "config" section to your app configuration.'); + return; + } + + this.setState({ migrationLoading: true }); + + try { + const result = await this.viewPreferencesManager.migrateToServer(this.context.applicationId); + if (result.success) { + if (result.viewCount > 0) { + this.showNote(`Successfully migrated ${result.viewCount} view(s) to server storage.`); + } else { + this.showNote('No views found to migrate.'); + } + } + } catch (error) { + this.showNote(`Failed to migrate views: ${error.message}`); + } finally { + this.setState({ migrationLoading: false }); + } + } + + async deleteFromBrowser() { + if (!window.confirm('Are you sure you want to delete all dashboard settings from browser storage? This action cannot be undone.')) { + return; + } + + if (!this.viewPreferencesManager) { + this.showNote('ViewPreferencesManager not initialized'); + return; + } + + const success = this.viewPreferencesManager.deleteFromBrowser(this.context.applicationId); + if (success) { + this.showNote('Successfully deleted views from browser storage.'); + } else { + this.showNote('Failed to delete views from browser storage.'); + } + } + getColumns() { const data = ColumnPreferences.getAllPreferences(this.context.applicationId); this.setState({ @@ -382,6 +461,61 @@ export default class DashboardSettings extends DashboardView { } /> + {this.viewPreferencesManager && this.viewPreferencesManager.isServerConfigEnabled() && ( +
+ + } + input={ + this.handleStoragePreferenceChange(preference)} + /> + } + /> + + } + input={ + this.migrateToServer()} + /> + } + /> + + } + input={ + this.deleteFromBrowser()} + /> + } + /> +
+ )} {this.state.copyData.show && copyData} {this.state.createUserInput && createUserInput} {this.state.newUser.show && userData} diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index 18e4ab02b..318c07fe8 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -50,6 +50,7 @@ export default class ParseApp { classPreference, enableSecurityChecks, cloudConfigHistoryLimit, + config, }) { this.name = appName; this.createdAt = created_at ? new Date(created_at) : new Date(); @@ -79,6 +80,7 @@ export default class ParseApp { this.scripts = scripts; this.enableSecurityChecks = !!enableSecurityChecks; this.cloudConfigHistoryLimit = cloudConfigHistoryLimit; + this.config = config; if (!supportedPushLocales) { console.warn( diff --git a/src/lib/ServerConfigStorage.js b/src/lib/ServerConfigStorage.js new file mode 100644 index 000000000..edc5f5f36 --- /dev/null +++ b/src/lib/ServerConfigStorage.js @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ + +import Parse from 'parse'; + +/** + * Utility class for storing dashboard configuration on Parse Server + */ +export default class ServerConfigStorage { + constructor(app) { + this.app = app; + this.className = app.config?.className || 'DashboardConfig'; + + // Validate className is a non-empty string + if (typeof this.className !== 'string' || !this.className.trim()) { + throw new Error('Invalid className for ServerConfigStorage'); + } + } + + /** + * Stores a configuration value on the server + * @param {string} key - The configuration key + * @param {*} value - The configuration value + * @param {string} appId - The app ID + * @param {string | null} userId - The user ID (optional) + * @returns {Promise} + */ + async setConfig(key, value, appId, userId = null) { + // First, try to find existing config object to update instead of creating duplicates + const query = new Parse.Query(this.className); + query.equalTo('appId', appId); + query.equalTo('key', key); + + if (userId) { + query.equalTo('user', new Parse.User({ objectId: userId })); + } else { + query.doesNotExist('user'); + } + + let configObject = await query.first({ useMasterKey: true }); + + // If no existing object found, create a new one + if (!configObject) { + configObject = new Parse.Object(this.className); + configObject.set('appId', appId); + configObject.set('key', key); + if (userId) { + configObject.set('user', new Parse.User({ objectId: userId })); + } + } + + // Set the value in the appropriate typed field based on value type + const valueType = this._getValueType(value); + this._clearAllValueFields(configObject); + configObject.set(valueType, value); + + // Use master key for operations + return configObject.save(null, { useMasterKey: true }); + } + + /** + * Gets a configuration value from the server + * @param {string} key - The configuration key + * @param {string} appId - The app ID + * @param {string | null} userId - The user ID (optional) + * @returns {Promise<*>} The configuration value + */ + async getConfig(key, appId, userId = null) { + const query = new Parse.Query(this.className); + query.equalTo('appId', appId); + query.equalTo('key', key); + + if (userId) { + query.equalTo('user', new Parse.User({ objectId: userId })); + } else { + query.doesNotExist('user'); + } + + const result = await query.first({ useMasterKey: true }); + if (!result) { + return null; + } + + return this._extractValue(result); + } + + /** + * Gets all configuration values for an app with a key prefix + * @param {string} keyPrefix - The key prefix to filter by + * @param {string} appId - The app ID + * @param {string | null} userId - The user ID (optional) + * @returns {Promise} Object with keys and values + */ + async getConfigsByPrefix(keyPrefix, appId, userId = null) { + const query = new Parse.Query(this.className); + query.equalTo('appId', appId); + query.startsWith('key', keyPrefix); + + if (userId) { + query.equalTo('user', new Parse.User({ objectId: userId })); + } else { + query.doesNotExist('user'); + } + + const results = await query.find({ useMasterKey: true }); + const configs = {}; + + results.forEach(result => { + const key = result.get('key'); + const value = this._extractValue(result); + configs[key] = value; + }); + + return configs; + } + + /** + * Deletes a configuration value from the server + * @param {string} key - The configuration key + * @param {string} appId - The app ID + * @param {string | null} userId - The user ID (optional) + * @returns {Promise} + */ + async deleteConfig(key, appId, userId = null) { + const query = new Parse.Query(this.className); + query.equalTo('appId', appId); + query.equalTo('key', key); + + if (userId) { + query.equalTo('user', new Parse.User({ objectId: userId })); + } else { + query.doesNotExist('user'); + } + + const result = await query.first({ useMasterKey: true }); + if (result) { + return result.destroy({ useMasterKey: true }); + } + } + + /** + * Checks if server configuration is available for this app + * @returns {boolean} + */ + isServerConfigEnabled() { + return !!(this.app && this.app.config && this.app.config.className); + } + + /** + * Gets the value type for a given value + * @private + */ + _getValueType(value) { + if (typeof value === 'boolean') { + return 'bool'; + } else if (typeof value === 'string') { + return 'string'; + } else if (typeof value === 'number') { + return 'number'; + } else if (Array.isArray(value)) { + return 'array'; + } else if (typeof value === 'object' && value !== null) { + return 'object'; + } + return 'string'; // fallback + } + + /** + * Clears all value fields on a config object + * @private + */ + _clearAllValueFields(configObject) { + configObject.unset('bool'); + configObject.unset('string'); + configObject.unset('number'); + configObject.unset('array'); + configObject.unset('object'); + } + + /** + * Extracts the value from a config object based on its type + * @private + */ + _extractValue(configObject) { + const fields = ['bool', 'string', 'number', 'array', 'object']; + for (const field of fields) { + const value = configObject.get(field); + if (value !== undefined && value !== null) { + return value; + } + } + return null; + } +} diff --git a/src/lib/StoragePreferences.js b/src/lib/StoragePreferences.js new file mode 100644 index 000000000..1eebf60c2 --- /dev/null +++ b/src/lib/StoragePreferences.js @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ + +/** + * Utility for managing user's storage preferences (server vs local storage) + */ + +const STORAGE_PREFERENCE_KEY = 'ParseDashboard:StoragePreferences'; + +/** + * Storage preference options + */ +export const STORAGE_TYPES = { + LOCAL: 'local', + SERVER: 'server' +}; + +/** + * Gets the storage preference for a specific app + * @param {string} appId - The application ID + * @returns {string} The storage preference ('local' or 'server') + */ +export function getStoragePreference(appId) { + try { + const preferences = localStorage.getItem(STORAGE_PREFERENCE_KEY); + if (preferences) { + const parsed = JSON.parse(preferences); + return parsed[appId] || STORAGE_TYPES.LOCAL; // Default to local storage + } + } catch (error) { + console.warn('Failed to get storage preference:', error); + } + return STORAGE_TYPES.LOCAL; // Default fallback +} + +/** + * Sets the storage preference for a specific app + * @param {string} appId - The application ID + * @param {string} preference - The storage preference ('local' or 'server') + */ +export function setStoragePreference(appId, preference) { + // Validate preference value + if (!Object.values(STORAGE_TYPES).includes(preference)) { + console.warn('Invalid storage preference:', preference); + return; + } + + try { + let preferences = {}; + const existing = localStorage.getItem(STORAGE_PREFERENCE_KEY); + if (existing) { + preferences = JSON.parse(existing); + } + + preferences[appId] = preference; + localStorage.setItem(STORAGE_PREFERENCE_KEY, JSON.stringify(preferences)); + } catch (error) { + console.warn('Failed to set storage preference:', error); + } +} + +/** + * Checks if the user prefers server storage for the given app + * @param {string} appId - The application ID + * @returns {boolean} True if user prefers server storage + */ +export function prefersServerStorage(appId) { + return getStoragePreference(appId) === STORAGE_TYPES.SERVER; +} + +/** + * Checks if the user prefers local storage for the given app + * @param {string} appId - The application ID + * @returns {boolean} True if user prefers local storage + */ +export function prefersLocalStorage(appId) { + return getStoragePreference(appId) === STORAGE_TYPES.LOCAL; +} diff --git a/src/lib/ViewPreferencesManager.js b/src/lib/ViewPreferencesManager.js new file mode 100644 index 000000000..27558851a --- /dev/null +++ b/src/lib/ViewPreferencesManager.js @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ + +import ServerConfigStorage from './ServerConfigStorage'; +import { prefersServerStorage, setStoragePreference } from './StoragePreferences'; + +const VERSION = 1; + +/** + * Enhanced ViewPreferences with server-side storage support + */ +export default class ViewPreferencesManager { + constructor(app) { + this.app = app; + this.serverStorage = new ServerConfigStorage(app); + } + + /** + * Gets views from either server or local storage based on configuration and user preference + * @param {string} appId - The application ID + * @returns {Promise} Array of views + */ + async getViews(appId) { + // Check if server storage is enabled and user prefers it + if (this.serverStorage.isServerConfigEnabled() && prefersServerStorage(appId)) { + try { + const serverViews = await this._getViewsFromServer(appId); + // Always return server views (even if empty) when server storage is preferred + return serverViews || []; + } catch (error) { + console.error('Failed to get views from server:', error); + // When server storage is preferred, return empty array instead of falling back to local + return []; + } + } + + // Use local storage when server storage is not preferred + return this._getViewsFromLocal(appId); + } + + /** + * Saves views to either server or local storage based on configuration and user preference + * @param {string} appId - The application ID + * @param {Array} views - Array of views to save + * @returns {Promise} + */ + async saveViews(appId, views) { + // Check if server storage is enabled and user prefers it + if (this.serverStorage.isServerConfigEnabled() && prefersServerStorage(appId)) { + try { + return await this._saveViewsToServer(appId, views); + } catch (error) { + console.error('Failed to save views to server:', error); + // On error, fallback to local storage + } + } + + // Use local storage (either by preference or as fallback) + return this._saveViewsToLocal(appId, views); + } + + /** + * Migrates views from local storage to server storage + * @param {string} appId - The application ID + * @returns {Promise<{success: boolean, viewCount: number}>} + */ + async migrateToServer(appId) { + if (!this.serverStorage.isServerConfigEnabled()) { + throw new Error('Server configuration is not enabled for this app'); + } + + const localViews = this._getViewsFromLocal(appId); + if (!localViews || localViews.length === 0) { + return { success: true, viewCount: 0 }; + } + + try { + await this._saveViewsToServer(appId, localViews); + return { success: true, viewCount: localViews.length }; + } catch (error) { + console.error('Failed to migrate views to server:', error); + throw error; + } + } + + /** + * Deletes views from local storage + * @param {string} appId - The application ID + * @returns {boolean} True if deletion was successful + */ + deleteFromBrowser(appId) { + try { + localStorage.removeItem(this._getLocalPath(appId)); + return true; + } catch (error) { + console.error('Failed to delete views from browser:', error); + return false; + } + } + + /** + * Sets the storage preference for the app + * @param {string} appId - The application ID + * @param {string} preference - The storage preference ('local' or 'server') + */ + setStoragePreference(appId, preference) { + setStoragePreference(appId, preference); + } + + /** + * Gets the current storage preference for the app + * @param {string} appId - The application ID + * @returns {string} The storage preference ('local' or 'server') + */ + getStoragePreference(appId) { + return prefersServerStorage(appId) ? 'server' : 'local'; + } + + /** + * Checks if server configuration is enabled for this app + * @returns {boolean} True if server config is enabled + */ + isServerConfigEnabled() { + return this.serverStorage.isServerConfigEnabled(); + } + + /** + * Gets views from server storage + * @private + */ + async _getViewsFromServer(appId) { + try { + const viewConfigs = await this.serverStorage.getConfigsByPrefix('views.view.', appId); + const views = []; + + Object.entries(viewConfigs).forEach(([key, config]) => { + if (config && typeof config === 'object') { + // Extract view ID from key (views.view.{VIEW_ID}) + const viewId = key.replace('views.view.', ''); + + // Parse the query if it's a string (it was stringified for storage) + const viewConfig = { ...config }; + if (viewConfig.query && typeof viewConfig.query === 'string') { + try { + viewConfig.query = JSON.parse(viewConfig.query); + } catch (e) { + console.warn('Failed to parse view query from server storage:', e); + console.error(`Skipping view ${viewId} due to corrupted query`); + // Skip views with corrupted queries instead of keeping them as strings + return; + } + } + + views.push({ + id: viewId, + ...viewConfig + }); + } + }); + + return views; + } catch (error) { + console.error('Failed to get views from server:', error); + return []; + } + } + + /** + * Saves views to server storage + * @private + */ + async _saveViewsToServer(appId, views) { + try { + // First, get existing views from server to know which ones to delete + const existingViewConfigs = await this.serverStorage.getConfigsByPrefix('views.view.', appId); + const existingViewIds = Object.keys(existingViewConfigs).map(key => + key.replace('views.view.', '') + ); + + // Delete views that are no longer in the new views array + const newViewIds = views.map(view => view.id || this._generateViewId(view)); + const viewsToDelete = existingViewIds.filter(id => !newViewIds.includes(id)); + + await Promise.all( + viewsToDelete.map(id => + this.serverStorage.deleteConfig(`views.view.${id}`, appId) + ) + ); + + // Save or update current views + await Promise.all( + views.map(view => { + const viewId = view.id || this._generateViewId(view); + const viewConfig = { ...view }; + delete viewConfig.id; // Don't store ID in the config itself + + // Remove null and undefined values to keep the storage clean + Object.keys(viewConfig).forEach(key => { + if (viewConfig[key] === null || viewConfig[key] === undefined) { + delete viewConfig[key]; + } + }); + + // Stringify the query if it exists and is an array/object + if (viewConfig.query && (Array.isArray(viewConfig.query) || typeof viewConfig.query === 'object')) { + viewConfig.query = JSON.stringify(viewConfig.query); + } + + return this.serverStorage.setConfig( + `views.view.${viewId}`, + viewConfig, + appId + ); + }) + ); + } catch (error) { + console.error('Failed to save views to server:', error); + throw error; + } + } + + /** + * Gets views from local storage (original implementation) + * @private + */ + _getViewsFromLocal(appId) { + let entry; + try { + entry = localStorage.getItem(this._getLocalPath(appId)) || '[]'; + } catch { + entry = '[]'; + } + try { + return JSON.parse(entry); + } catch { + return []; + } + } + + /** + * Saves views to local storage (original implementation) + * @private + */ + _saveViewsToLocal(appId, views) { + try { + localStorage.setItem(this._getLocalPath(appId), JSON.stringify(views)); + } catch { + // ignore write errors + } + } + + /** + * Gets the local storage path for views + * @private + */ + _getLocalPath(appId) { + return `ParseDashboard:${VERSION}:${appId}:Views`; + } + + /** + * Generates a unique ID for a view + * @private + */ + _generateViewId(view) { + if (view.id) { + return view.id; + } + // Generate a unique ID based on view name, timestamp, and random component + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substr(2, 5); + const nameHash = view.name ? view.name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() : 'view'; + return `${nameHash}_${timestamp}_${random}`; + } +} + +// Legacy API compatibility - these functions will work with local storage only +// for backward compatibility +export function getViews(appId) { + let entry; + try { + entry = localStorage.getItem(path(appId)) || '[]'; + } catch { + entry = '[]'; + } + try { + return JSON.parse(entry); + } catch { + return []; + } +} + +export function saveViews(appId, views) { + try { + localStorage.setItem(path(appId), JSON.stringify(views)); + } catch { + // ignore write errors + } +} + +function path(appId) { + return `ParseDashboard:${VERSION}:${appId}:Views`; +} From 00eb2d17a5d8d1681f98dce41700372acb98311e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 3 Aug 2025 12:06:13 +0000 Subject: [PATCH 03/13] chore(release): 7.4.0-alpha.1 [skip ci] # [7.4.0-alpha.1](https://github.com/parse-community/parse-dashboard/compare/7.3.0...7.4.0-alpha.1) (2025-08-03) ### Features * Add App Settings option to store dashboard settings on server ([#2958](https://github.com/parse-community/parse-dashboard/issues/2958)) ([666e078](https://github.com/parse-community/parse-dashboard/commit/666e07860ba1478aec3cde9db5c98a5772ea07fb)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 4996f4a93..2703b8aeb 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [7.4.0-alpha.1](https://github.com/parse-community/parse-dashboard/compare/7.3.0...7.4.0-alpha.1) (2025-08-03) + + +### Features + +* Add App Settings option to store dashboard settings on server ([#2958](https://github.com/parse-community/parse-dashboard/issues/2958)) ([666e078](https://github.com/parse-community/parse-dashboard/commit/666e07860ba1478aec3cde9db5c98a5772ea07fb)) + # [7.3.0-alpha.44](https://github.com/parse-community/parse-dashboard/compare/7.3.0-alpha.43...7.3.0-alpha.44) (2025-07-31) diff --git a/package-lock.json b/package-lock.json index 6d6ecf383..418c37421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-dashboard", - "version": "7.3.0", + "version": "7.4.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parse-dashboard", - "version": "7.3.0", + "version": "7.4.0-alpha.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@babel/runtime": "7.27.4", diff --git a/package.json b/package.json index 95f93da9e..6130da56f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-dashboard", - "version": "7.3.0", + "version": "7.4.0-alpha.1", "repository": { "type": "git", "url": "https://github.com/parse-community/parse-dashboard" From 6e0c7f25b16fdfc0a34123359a8512c26252f269 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 4 Aug 2025 15:18:34 +0200 Subject: [PATCH 04/13] feat: Modernize JavaScript console with tabs and server-side storage of scripts (#2962) --- src/components/BrowserMenu/MenuItem.react.js | 23 +- src/components/CodeEditor/CodeEditor.react.js | 39 +- .../Data/Playground/Playground.react.js | 1432 +++++++++++++++-- src/dashboard/Data/Playground/Playground.scss | 416 ++++- src/lib/ScriptManager.js | 422 +++++ 5 files changed, 2191 insertions(+), 141 deletions(-) create mode 100644 src/lib/ScriptManager.js diff --git a/src/components/BrowserMenu/MenuItem.react.js b/src/components/BrowserMenu/MenuItem.react.js index 84eedf1ad..2cd09381a 100644 --- a/src/components/BrowserMenu/MenuItem.react.js +++ b/src/components/BrowserMenu/MenuItem.react.js @@ -8,7 +8,7 @@ import React from 'react'; import styles from 'components/BrowserMenu/BrowserMenu.scss'; -const MenuItem = ({ text, disabled, active, greenActive, onClick }) => { +const MenuItem = ({ text, shortcut, disabled, active, greenActive, onClick, disableMouseDown = false }) => { const classes = [styles.item]; if (disabled) { classes.push(styles.disabled); @@ -30,14 +30,29 @@ const MenuItem = ({ text, disabled, active, greenActive, onClick }) => {
- {text} + {text} + {shortcut && ( + + {shortcut} + + )}
); }; diff --git a/src/components/CodeEditor/CodeEditor.react.js b/src/components/CodeEditor/CodeEditor.react.js index e8554fe7e..4a3c75521 100644 --- a/src/components/CodeEditor/CodeEditor.react.js +++ b/src/components/CodeEditor/CodeEditor.react.js @@ -10,10 +10,23 @@ import Editor from 'react-ace'; import PropTypes from '../../lib/PropTypes'; import 'ace-builds/src-noconflict/mode-javascript'; -import 'ace-builds/src-noconflict/theme-solarized_dark'; +import 'ace-builds/src-noconflict/theme-monokai'; import 'ace-builds/src-noconflict/snippets/javascript'; import 'ace-builds/src-noconflict/ext-language_tools'; +// Disable web workers to prevent MIME type errors +import ace from 'ace-builds/src-noconflict/ace'; + +// Configure ACE to disable workers globally +ace.config.set('useWorker', false); +ace.config.set('loadWorkerFromBlob', false); +ace.config.set('workerPath', false); + +// Also set the base path to prevent worker loading attempts +ace.config.set('basePath', '/bundles'); +ace.config.set('modePath', '/bundles'); +ace.config.set('themePath', '/bundles'); + export default class CodeEditor extends React.Component { constructor(props) { super(props); @@ -32,13 +45,13 @@ export default class CodeEditor extends React.Component { } render() { - const { fontSize = 18 } = this.props; + const { fontSize = 18, theme = 'monokai' } = this.props; const { code } = this.state; return ( this.setState({ code: value })} fontSize={fontSize} showPrintMargin={true} @@ -49,8 +62,25 @@ export default class CodeEditor extends React.Component { enableBasicAutocompletion={true} enableLiveAutocompletion={true} enableSnippets={false} - showLineNumbers={true} tabSize={2} + style={{ + backgroundColor: '#202020' + }} + setOptions={{ + useWorker: false, // Disable web workers to prevent MIME type errors + wrap: true, + foldStyle: 'markbegin', + enableMultiselect: true, + // Additional worker-related options + enableBasicAutocompletion: true, + enableLiveAutocompletion: true, + enableSnippets: false, + }} + editorProps={{ + $blockScrolling: Infinity, // Disable annoying warning + $useWorker: false, // Additional worker disable + }} + commands={[]} // Disable any commands that might trigger worker loading /> ); } @@ -59,4 +89,5 @@ export default class CodeEditor extends React.Component { CodeEditor.propTypes = { fontSize: PropTypes.number.describe('Font size of the editor'), defaultValue: PropTypes.string.describe('Default Code'), + theme: PropTypes.string.describe('Theme for the editor'), }; diff --git a/src/dashboard/Data/Playground/Playground.react.js b/src/dashboard/Data/Playground/Playground.react.js index b741d2dd7..8ba356101 100644 --- a/src/dashboard/Data/Playground/Playground.react.js +++ b/src/dashboard/Data/Playground/Playground.react.js @@ -1,12 +1,16 @@ -import React, { Component } from 'react'; +import React, { useState, useRef, useEffect, useContext, useCallback, useMemo } from 'react'; import ReactJson from 'react-json-view'; import Parse from 'parse'; import CodeEditor from 'components/CodeEditor/CodeEditor.react'; -import Button from 'components/Button/Button.react'; -import SaveButton from 'components/SaveButton/SaveButton.react'; import Toolbar from 'components/Toolbar/Toolbar.react'; +import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react'; +import MenuItem from 'components/BrowserMenu/MenuItem.react'; +import Icon from 'components/Icon/Icon.react'; import { CurrentApp } from 'context/currentApp'; +import browserStyles from 'dashboard/Data/Browser/Browser.scss'; +import Separator from 'components/BrowserMenu/Separator.react'; +import ScriptManager from 'lib/ScriptManager'; import styles from './Playground.scss'; @@ -15,69 +19,819 @@ myObj.set('myField', 'Hello World!') await myObj.save(); console.log(myObj);`; -export default class Playground extends Component { - static contextType = CurrentApp; - constructor() { - super(); - this.section = 'Core'; - this.subsection = 'JS Console'; - this.localKey = 'parse-dashboard-playground-code'; - this.state = { - results: [], - running: false, - saving: false, - savingState: SaveButton.States.WAITING, +const LOG_TYPES = { + LOG: 'log', + ERROR: 'error', + WARN: 'warn', + INFO: 'info', + DEBUG: 'debug' +}; + +const formatLogValue = (value, seen = new WeakSet(), depth = 0) => { + // Prevent infinite recursion with depth limit + if (depth > 10) { + return { __type: 'MaxDepthReached', value: '[Too deep to serialize]' }; + } + + // Handle null and undefined + if (value === null || value === undefined) { + return value; + } + + // Handle primitive types that are JSON-safe + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + // Prevent circular references for objects + if (typeof value === 'object' && seen.has(value)) { + return { __type: 'CircularReference', value: '[Circular Reference]' }; + } + + // Handle functions + if (typeof value === 'function') { + return { + __type: 'Function', + name: value.name || 'anonymous', + value: value.toString().substring(0, 200) + (value.toString().length > 200 ? '...' : '') }; } - overrideConsole() { + // Add to seen set for circular reference detection + if (typeof value === 'object') { + seen.add(value); + } + + try { + // Handle Parse Objects + if (value instanceof Parse.Object) { + const result = { + __type: 'Parse.Object', + className: value.className, + objectId: value.id, + createdAt: value.createdAt, + updatedAt: value.updatedAt + }; + + // Safely add attributes + try { + Object.keys(value.attributes).forEach(key => { + result[key] = formatLogValue(value.attributes[key], seen, depth + 1); + }); + } catch { + result.attributes = '[Error accessing attributes]'; + } + + return result; + } + + // Handle Errors + if (value instanceof Error) { + return { + __type: 'Error', + name: value.name, + message: value.message, + stack: value.stack + }; + } + + // Handle Arrays + if (Array.isArray(value)) { + try { + return value.slice(0, 100).map(item => formatLogValue(item, seen, depth + 1)); + } catch { + return { __type: 'Array', length: value.length, value: '[Array]' }; + } + } + + // Handle Date objects + if (value instanceof Date) { + return { + __type: 'Date', + value: value.toISOString() + }; + } + + // Handle RegExp objects + if (value instanceof RegExp) { + return { + __type: 'RegExp', + value: value.toString() + }; + } + + // Handle other objects + if (value && typeof value === 'object') { + try { + // First try to JSON serialize to check if it's valid + const serialized = JSON.stringify(value); + return JSON.parse(serialized); + } catch { + // If serialization fails, create a safe representation + try { + const safeObj = {}; + const keys = Object.keys(value).slice(0, 20); // Further reduced to 20 keys + + for (const key of keys) { + try { + if (value.hasOwnProperty(key)) { + safeObj[key] = formatLogValue(value[key], seen, depth + 1); + } + } catch { + safeObj[key] = { __type: 'UnserializableValue', value: '[Cannot serialize]' }; + } + } + + if (Object.keys(value).length > 20) { + safeObj.__truncated = `... and ${Object.keys(value).length - 20} more properties`; + } + + return { __type: 'Object', ...safeObj }; + } catch { + return { __type: 'Object', value: String(value) }; + } + } + } + } catch (error) { + return { __type: 'SerializationError', value: String(value), error: error.message }; + } + + // Fallback for any other type + return { __type: typeof value, value: String(value) }; +}; + +export default function Playground() { + const context = useContext(CurrentApp); + const editorRef = useRef(null); + const consoleOutputRef = useRef(null); + const scriptManagerRef = useRef(null); + const [results, setResults] = useState([]); + const [running, setRunning] = useState(false); + const [saving, setSaving] = useState(false); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [editorHeight, setEditorHeight] = useState(50); // Percentage of the container height + const [isResizing, setIsResizing] = useState(false); + const [isAtBottom, setIsAtBottom] = useState(true); // Track if user is at bottom of console + const containerRef = useRef(null); + + // Tab management state + const [tabs, setTabs] = useState([ + { id: 1, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE } + ]); + const [activeTabId, setActiveTabId] = useState(1); + const [nextTabId, setNextTabId] = useState(2); + const [renamingTabId, setRenamingTabId] = useState(null); + const [renamingValue, setRenamingValue] = useState(''); + const [savedTabs, setSavedTabs] = useState([]); // All saved tabs including closed ones + const [, setCurrentMenu] = useState(null); // Track which menu is currently open + const [, setForceUpdate] = useState({}); // Force re-render for unsaved changes detection + const renamingInputRef = useRef(null); + + // Drag and drop state + const [draggedTabId, setDraggedTabId] = useState(null); + const [dragOverTabId, setDragOverTabId] = useState(null); + + // Initialize ScriptManager + useEffect(() => { + if (!scriptManagerRef.current && context) { + scriptManagerRef.current = new ScriptManager(context); + } + }, [context]); + + const section = 'Core'; + const subsection = 'JS Console'; + const historyKey = 'parse-dashboard-playground-history'; + const heightKey = 'parse-dashboard-playground-height'; + + // Load saved code, tabs, and history on mount + useEffect(() => { + const loadData = async () => { + if (!scriptManagerRef.current || !context?.applicationId) { + return; + } + + try { + // Load open scripts (those with order property) + const openScripts = await scriptManagerRef.current.getOpenScripts(context.applicationId); + // Load all scripts to check for unsaved ones (like legacy scripts) + const allScripts = await scriptManagerRef.current.getScripts(context.applicationId); + // Load all saved scripts for the tabs menu + const allSavedScripts = await scriptManagerRef.current.getAllSavedScripts(context.applicationId); + + // Find unsaved scripts (like legacy scripts) that should also be opened + const unsavedScripts = allScripts.filter(script => + script.saved === false && !openScripts.find(openScript => openScript.id === script.id) + ); + + // Combine open scripts with unsaved scripts, giving unsaved scripts an order + const tabsToOpen = [...openScripts]; + if (unsavedScripts.length > 0) { + const maxOrder = openScripts.length > 0 ? Math.max(...openScripts.map(s => s.order)) : -1; + unsavedScripts.forEach((script, index) => { + tabsToOpen.push({ ...script, order: maxOrder + 1 + index }); + }); + } + + if (tabsToOpen.length > 0) { + setTabs(tabsToOpen); + const maxId = Math.max(...allScripts.map(tab => tab.id)); + setNextTabId(maxId + 1); + + // Set active tab to the first one + setActiveTabId(tabsToOpen[0].id); + + setSavedTabs(allSavedScripts); + } else { + // If no scripts at all, try to get any scripts and open the first one + if (allScripts && allScripts.length > 0) { + // Open the first script + const firstScript = { ...allScripts[0], order: 0 }; + setTabs([firstScript]); + setActiveTabId(firstScript.id); + const maxId = Math.max(...allScripts.map(tab => tab.id)); + setNextTabId(maxId + 1); + + // Save it as open + await scriptManagerRef.current.openScript(context.applicationId, firstScript.id, 0); + + setSavedTabs(allScripts.filter(script => script.saved !== false)); + } else { + // Fallback to default tab if no scripts exist + setTabs([{ id: 1, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE, order: 0 }]); + setActiveTabId(1); + setNextTabId(2); + } + } + } catch (error) { + console.warn('Failed to load scripts via ScriptManager:', error); + // Fallback to default tab if loading fails + setTabs([{ id: 1, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE, order: 0 }]); + setActiveTabId(1); + setNextTabId(2); + } + + // Load other data from localStorage + if (window.localStorage) { + const savedHistory = window.localStorage.getItem(historyKey); + if (savedHistory) { + try { + setHistory(JSON.parse(savedHistory)); + } catch (e) { + console.warn('Failed to load execution history:', e); + } + } + + const savedHeight = window.localStorage.getItem(heightKey); + if (savedHeight) { + try { + const height = parseFloat(savedHeight); + if (height >= 0 && height <= 100) { + setEditorHeight(height); + } + } catch (e) { + console.warn('Failed to load saved height:', e); + } + } + } + }; + + loadData(); + }, [context?.applicationId, historyKey, heightKey]); + + // Get current active tab + const activeTab = tabs.find(tab => tab.id === activeTabId) || tabs[0]; + + // Update editor when active tab changes + useEffect(() => { + if (editorRef.current && activeTab) { + editorRef.current.value = activeTab.code; + } + }, [activeTabId, activeTab]); + + // Helper function to close menu after action + const executeAndCloseMenu = useCallback((action) => { + action(); + setCurrentMenu(null); + }, []); + + // Tab management functions + const createNewTab = useCallback(() => { + const newTab = { + id: nextTabId, + name: `Tab ${nextTabId}`, + code: '', // Start with empty code instead of default value + saved: false, // Mark as unsaved initially + order: tabs.length // Assign order as the last position + }; + const updatedTabs = [...tabs, newTab]; + setTabs(updatedTabs); + setActiveTabId(nextTabId); + setNextTabId(nextTabId + 1); + }, [tabs, nextTabId]); + + const closeTab = useCallback(async (tabId) => { + if (tabs.length <= 1) { + return; // Don't close the last tab + } + + // Find the tab to get its name and check for unsaved changes + const tabToClose = tabs.find(tab => tab.id === tabId); + const tabName = tabToClose ? tabToClose.name : 'this tab'; + + // Get current content (either from editor if it's the active tab, or from tab's stored code) + let currentContent = ''; + if (tabId === activeTabId && editorRef.current) { + currentContent = editorRef.current.value; + } else if (tabToClose) { + currentContent = tabToClose.code; + } + + // Check if the tab is empty (no content at all) + const isEmpty = !currentContent.trim(); + + // Check if there are unsaved changes (only for non-empty tabs) + let hasUnsavedChanges = false; + if (!isEmpty && tabId === activeTabId && editorRef.current && tabToClose) { + const savedContent = tabToClose.code; + hasUnsavedChanges = currentContent !== savedContent; + } + + // Show confirmation dialog only if there are unsaved changes and the tab is not empty + if (!isEmpty && hasUnsavedChanges) { + const confirmed = window.confirm( + `Are you sure you want to close "${tabName}"?\n\nAny unsaved changes will be lost.` + ); + + if (!confirmed) { + return; // User cancelled, don't close the tab + } + } + + const updatedTabs = tabs.filter(tab => tab.id !== tabId); + setTabs(updatedTabs); + + // If closing active tab, switch to another tab + if (tabId === activeTabId) { + const newActiveTab = updatedTabs[0]; + setActiveTabId(newActiveTab.id); + } + + // Update tab orders for remaining tabs + const reorderedTabs = updatedTabs.map((tab, index) => ({ + ...tab, + order: index + })); + setTabs(reorderedTabs); + + // Save the current content to the script before closing (if not empty) + if (!isEmpty && scriptManagerRef.current && context?.applicationId) { + try { + // First save the current content to the script + const allScripts = await scriptManagerRef.current.getScripts(context.applicationId); + const updatedScripts = allScripts.map(script => + script.id === tabId + ? { ...script, code: currentContent, lastModified: Date.now() } + : script + ); + await scriptManagerRef.current.saveScripts(context.applicationId, updatedScripts); + + // Then close the script (remove order property) + await scriptManagerRef.current.closeScript(context.applicationId, tabId); + + // Update the order of remaining open tabs + await scriptManagerRef.current.updateScriptOrder(context.applicationId, reorderedTabs); + } catch (error) { + console.error('Failed to close script:', error); + } + } else if (isEmpty && scriptManagerRef.current && context?.applicationId) { + // For empty tabs, just close them + try { + await scriptManagerRef.current.closeScript(context.applicationId, tabId); + await scriptManagerRef.current.updateScriptOrder(context.applicationId, reorderedTabs); + + // Remove from saved tabs if it was empty + const updatedSavedTabs = savedTabs.filter(saved => saved.id !== tabId); + setSavedTabs(updatedSavedTabs); + } catch (error) { + console.error('Failed to close empty script:', error); + } + } + }, [tabs, activeTabId, savedTabs, context?.applicationId]); + + const switchTab = useCallback((tabId) => { + // Update current tab's code in memory before switching (but don't save) + if (editorRef.current && activeTab) { + const currentCode = editorRef.current.value; + const updatedTabs = tabs.map(tab => + tab.id === activeTabId + ? { ...tab, code: currentCode } + : tab + ); + setTabs(updatedTabs); + } + + setActiveTabId(tabId); + }, [tabs, activeTabId, activeTab]); + + const renameTab = useCallback((tabId, newName) => { + if (!newName.trim()) { + return; + } + + const updatedTabs = tabs.map(tab => + tab.id === tabId ? { ...tab, name: newName.trim() } : tab + ); + setTabs(updatedTabs); + }, [tabs]); + + const startRenaming = useCallback((tabId, currentName) => { + setRenamingTabId(tabId); + setRenamingValue(currentName); + }, []); + + const cancelRenaming = useCallback(() => { + setRenamingTabId(null); + setRenamingValue(''); + }, []); + + const confirmRenaming = useCallback(() => { + if (renamingTabId && renamingValue.trim()) { + renameTab(renamingTabId, renamingValue); + } + cancelRenaming(); + }, [renamingTabId, renamingValue, renameTab, cancelRenaming]); + + // Drag and drop handlers + const handleDragStart = useCallback((e, tabId) => { + setDraggedTabId(tabId); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', e.target); + }, []); + + const handleDragOver = useCallback((e, tabId) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverTabId(tabId); + }, []); + + const handleDragLeave = useCallback(() => { + setDragOverTabId(null); + }, []); + + const handleDrop = useCallback(async (e, targetTabId) => { + e.preventDefault(); + + if (!draggedTabId || draggedTabId === targetTabId) { + setDraggedTabId(null); + setDragOverTabId(null); + return; + } + + // Find the indices of the dragged and target tabs + const draggedIndex = tabs.findIndex(tab => tab.id === draggedTabId); + const targetIndex = tabs.findIndex(tab => tab.id === targetTabId); + + if (draggedIndex === -1 || targetIndex === -1) { + setDraggedTabId(null); + setDragOverTabId(null); + return; + } + + // Create new tab order + const newTabs = [...tabs]; + const [draggedTab] = newTabs.splice(draggedIndex, 1); + newTabs.splice(targetIndex, 0, draggedTab); + + // Update order property for all tabs + const reorderedTabs = newTabs.map((tab, index) => ({ + ...tab, + order: index + })); + + setTabs(reorderedTabs); + + // Save the new order using ScriptManager + if (scriptManagerRef.current && context?.applicationId) { + try { + await scriptManagerRef.current.updateScriptOrder(context.applicationId, reorderedTabs); + } catch (error) { + console.error('Failed to update script order:', error); + } + } + + setDraggedTabId(null); + setDragOverTabId(null); + }, [draggedTabId, tabs, context?.applicationId]); + + const handleDragEnd = useCallback(() => { + setDraggedTabId(null); + setDragOverTabId(null); + }, []); + + const deleteTabFromSaved = useCallback(async (tabId) => { + // Find the tab to get its name for confirmation + const tabToDelete = tabs.find(tab => tab.id === tabId) || savedTabs.find(tab => tab.id === tabId); + const tabName = tabToDelete ? tabToDelete.name : 'this tab'; + + // Show confirmation dialog + const confirmed = window.confirm( + `Are you sure you want to permanently delete "${tabName}" from saved tabs?\n\nThis action cannot be undone.` + ); + + if (!confirmed) { + return; // User cancelled + } + + // If the tab is currently open, close it first + const isCurrentlyOpen = tabs.find(tab => tab.id === tabId); + if (isCurrentlyOpen) { + const updatedTabs = tabs.filter(tab => tab.id !== tabId); + setTabs(updatedTabs); + + // If closing active tab, switch to another tab + if (tabId === activeTabId && updatedTabs.length > 0) { + setActiveTabId(updatedTabs[0].id); + } + } + + // Remove from saved tabs state + const updatedSavedTabs = savedTabs.filter(saved => saved.id !== tabId); + setSavedTabs(updatedSavedTabs); + + // Completely delete the script from storage using ScriptManager + if (scriptManagerRef.current && context?.applicationId) { + try { + await scriptManagerRef.current.deleteScript(context.applicationId, tabId); + } catch (error) { + console.error('Failed to delete script:', error); + } + } + }, [tabs, savedTabs, activeTabId, context?.applicationId]); + + const reopenTab = useCallback(async (savedTab) => { + // Check if tab is already open + const isAlreadyOpen = tabs.find(tab => tab.id === savedTab.id); + if (isAlreadyOpen) { + // Just switch to the tab if it's already open + switchTab(savedTab.id); + return; + } + + // Create a new tab based on the saved tab + const reopenedTab = { + id: savedTab.id, + name: savedTab.name, + code: savedTab.code, + saved: true, // Mark as saved since it's from saved tabs + order: tabs.length // Add as last tab + }; + + const updatedTabs = [...tabs, reopenedTab]; + setTabs(updatedTabs); + setActiveTabId(savedTab.id); + + // Update nextTabId if necessary + if (savedTab.id >= nextTabId) { + setNextTabId(savedTab.id + 1); + } + + // Save the open state through ScriptManager + if (scriptManagerRef.current && context?.applicationId) { + try { + await scriptManagerRef.current.openScript(context.applicationId, savedTab.id, tabs.length); + } catch (error) { + console.error('Failed to open script:', error); + } + } + }, [tabs, nextTabId, switchTab, context?.applicationId]); + + // Focus input when starting to rename + useEffect(() => { + if (renamingTabId && renamingInputRef.current) { + renamingInputRef.current.focus(); + renamingInputRef.current.select(); + } + }, [renamingTabId]); + + // Handle mouse down on resize handle + const handleResizeStart = useCallback((e) => { + e.preventDefault(); + setIsResizing(true); + + const handleMouseMove = (e) => { + if (!containerRef.current) { + return; + } + + const rect = containerRef.current.getBoundingClientRect(); + const containerHeight = rect.height; + const relativeY = e.clientY - rect.top; + + // Calculate percentage (0% to 100% range) + let percentage = (relativeY / containerHeight) * 100; + percentage = Math.max(0, Math.min(100, percentage)); + + setEditorHeight(percentage); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + // Save the height to localStorage + if (window.localStorage) { + try { + window.localStorage.setItem(heightKey, editorHeight.toString()); + } catch (e) { + console.warn('Failed to save height:', e); + } + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [editorHeight, heightKey]); + + // Check if console is scrolled to bottom + const checkIfAtBottom = useCallback(() => { + if (!consoleOutputRef.current) { + return true; + } + + const { scrollTop, scrollHeight, clientHeight } = consoleOutputRef.current; + const threshold = 5; // 5px threshold for "at bottom" + return scrollHeight - scrollTop - clientHeight <= threshold; + }, []); + + // Handle console scroll + const handleConsoleScroll = useCallback(() => { + setIsAtBottom(checkIfAtBottom()); + }, [checkIfAtBottom]); + + // Auto-scroll to bottom when new results are added + useEffect(() => { + if (isAtBottom && consoleOutputRef.current) { + consoleOutputRef.current.scrollTop = consoleOutputRef.current.scrollHeight; + } + }, [results, isAtBottom]); + + // Create console override function + const createConsoleOverride = useCallback(() => { const originalConsoleLog = console.log; const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + const originalConsoleInfo = console.info; + const originalConsoleDebug = console.debug; - console.log = (...args) => { - this.setState(({ results }) => ({ - results: [ - ...results, - ...args.map(arg => ({ - log: - typeof arg === 'object' - ? Array.isArray(arg) - ? arg.map(this.getParseObjectAttr) - : this.getParseObjectAttr(arg) - : { result: arg }, - name: 'Log', - })), - ], - })); + // Flag to prevent recursive console calls during formatting + let isProcessing = false; + + const addResult = (type, args) => { + // Prevent recursive calls during formatting + if (isProcessing) { + return; + } + + isProcessing = true; + + try { + const timestamp = new Date().toLocaleTimeString(); + // Capture stack trace to find the calling location + const stack = new Error().stack; + let sourceLocation = null; + + if (stack) { + const stackLines = stack.split('\n'); + // Look for the first line that contains 'eval' or 'Function' (user code) + for (let i = 1; i < stackLines.length; i++) { + const line = stackLines[i]; + if (line.includes('eval') || line.includes('Function')) { + // Try to extract line number from eval context + const evalMatch = line.match(/eval.*:(\d+):(\d+)/); + if (evalMatch) { + sourceLocation = { + file: 'User Code', + line: parseInt(evalMatch[1]) - 8, // Adjust for wrapper function lines + column: parseInt(evalMatch[2]) + }; + break; + } + } + } + } + + // Safely format arguments with error handling to prevent infinite loops + const formattedArgs = args.map((arg, index) => { + try { + const result = formatLogValue(arg); + return result; + } catch (error) { + console.warn('Error formatting argument ' + index + ':', error); + return { __type: 'FormattingError', value: String(arg), error: error.message }; + } + }); + + setResults(prevResults => [ + ...prevResults, + { + type, + timestamp, + args: formattedArgs, + sourceLocation, + id: Date.now() + Math.random() // Simple unique ID + } + ]); + } catch (error) { + console.error('Error in addResult:', error); + } finally { + isProcessing = false; + } + }; + + // Helper function to check if error is from ReactJson and should be ignored + const isReactJsonError = (args) => { + return args.length > 0 && + typeof args[0] === 'string' && + (args[0].includes('react-json-view error') || + args[0].includes('src property must be a valid json object')); + }; + + console.log = (...args) => { + addResult(LOG_TYPES.LOG, args); originalConsoleLog.apply(console, args); }; + console.error = (...args) => { - this.setState(({ results }) => ({ - results: [ - ...results, - ...args.map(arg => ({ - log: - arg instanceof Error - ? { message: arg.message, name: arg.name, stack: arg.stack } - : { result: arg }, - name: 'Error', - })), - ], - })); + // Skip ReactJson errors to prevent infinite loop + if (isReactJsonError(args)) { + originalConsoleError.apply(console, args); + return; + } + addResult(LOG_TYPES.ERROR, args); originalConsoleError.apply(console, args); }; - return [originalConsoleLog, originalConsoleError]; - } + console.warn = (...args) => { + // Skip ReactJson warnings to prevent infinite loop + if (isReactJsonError(args)) { + originalConsoleWarn.apply(console, args); + return; + } + + addResult(LOG_TYPES.WARN, args); + originalConsoleWarn.apply(console, args); + }; + + console.info = (...args) => { + addResult(LOG_TYPES.INFO, args); + originalConsoleInfo.apply(console, args); + }; + + console.debug = (...args) => { + addResult(LOG_TYPES.DEBUG, args); + originalConsoleDebug.apply(console, args); + }; + + return () => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + console.info = originalConsoleInfo; + console.debug = originalConsoleDebug; + }; + }, []); + + // Run code function + const runCode = useCallback(async () => { + if (!editorRef.current || running) { + return; + } + + const code = editorRef.current.value; + if (!code.trim()) { + return; + } + + // Update current tab's code in memory before running (but don't auto-save) + if (activeTab) { + const updatedTabs = tabs.map(tab => + tab.id === activeTabId + ? { ...tab, code: code } + : tab + ); + setTabs(updatedTabs); + } - async runCode() { - const [originalConsoleLog, originalConsoleError] = this.overrideConsole(); + const restoreConsole = createConsoleOverride(); + setRunning(true); + setResults([]); try { - const { applicationId, masterKey, serverURL, javascriptKey } = this.context; - const originalCode = this.editor.value; + const { applicationId, masterKey, serverURL, javascriptKey } = context; const finalCode = `return (async function(){ try{ @@ -85,110 +839,534 @@ export default class Playground extends Component { Parse.masterKey = '${masterKey}'; Parse.serverUrl = '${serverURL}'; - ${originalCode} + ${code} } catch(e) { console.error(e); } })()`; - this.setState({ running: true, results: [] }); - await new Function('Parse', finalCode)(Parse); + + // Add to history + const newHistory = [code, ...history.slice(0, 19)]; // Keep last 20 items + setHistory(newHistory); + setHistoryIndex(-1); + + if (window.localStorage) { + try { + window.localStorage.setItem(historyKey, JSON.stringify(newHistory)); + } catch (e) { + console.warn('Failed to save execution history:', e); + } + } } catch (e) { - console.error(e); + console.error('Execution error:', e); } finally { - console.log = originalConsoleLog; - console.error = originalConsoleError; - this.setState({ running: false }); + restoreConsole(); + setRunning(false); + } + }, [context, createConsoleOverride, running, history, historyKey, tabs, activeTabId, activeTab]); + + // Save code function - this is the ONLY way tabs get saved to saved tabs + const saveCode = useCallback(async () => { + if (!editorRef.current || saving || !scriptManagerRef.current || !context?.applicationId) { + return; } - } - saveCode() { try { - this.setState({ saving: true, savingState: SaveButton.States.SAVING }); - const code = this.editor.value; + setSaving(true); + const code = editorRef.current.value; + + // Update current tab's code + const updatedTabs = tabs.map(tab => + tab.id === activeTabId + ? { ...tab, code: code, saved: true, lastModified: Date.now() } + : tab + ); + setTabs(updatedTabs); + + // Save all tabs using ScriptManager + await scriptManagerRef.current.saveScripts(context.applicationId, updatedTabs); - window.localStorage.setItem(this.localKey, code); - this.setState({ - saving: false, - savingState: SaveButton.States.SUCCEEDED, - }); + // Update saved tabs state + const currentTab = updatedTabs.find(tab => tab.id === activeTabId); + if (currentTab) { + const updatedSavedTabs = [...savedTabs]; + const existingIndex = updatedSavedTabs.findIndex(saved => saved.id === currentTab.id); - setTimeout(() => this.setState({ savingState: SaveButton.States.WAITING }), 3000); + if (existingIndex >= 0) { + // Update existing saved tab + updatedSavedTabs[existingIndex] = { ...currentTab }; + } else { + // Add new tab to saved tabs + updatedSavedTabs.push({ ...currentTab }); + } + + setSavedTabs(updatedSavedTabs); + } + + // Show brief feedback that save was successful + setTimeout(() => setSaving(false), 1000); } catch (e) { - console.error(e); - this.setState({ saving: false, savingState: SaveButton.States.FAILED }); + console.error('Save error:', e); + setSaving(false); } - } + }, [saving, tabs, activeTabId, savedTabs, context?.applicationId]); - getParseObjectAttr(parseObject) { - if (parseObject instanceof Parse.Object) { - return parseObject.attributes; + // Clear console + const clearConsole = useCallback(() => { + setResults([]); + }, []); + + // Navigate through history + const navigateHistory = useCallback((direction) => { + if (!editorRef.current || history.length === 0) { + return; } - return parseObject; - } + let newIndex; + if (direction === 'up') { + newIndex = Math.min(historyIndex + 1, history.length - 1); + } else { + newIndex = Math.max(historyIndex - 1, -1); + } + + setHistoryIndex(newIndex); - componentDidMount() { - if (window.localStorage) { - const initialCode = window.localStorage.getItem(this.localKey); - if (initialCode) { - this.editor.value = initialCode; + if (newIndex === -1) { + // Restore to empty or current content + return; + } + + editorRef.current.value = history[newIndex]; + }, [history, historyIndex]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e) => { + // Ctrl/Cmd + Enter to run + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + runCode(); + } + // Ctrl/Cmd + S to save + else if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + saveCode(); + } + // Ctrl/Cmd + L to clear console + else if ((e.ctrlKey || e.metaKey) && e.key === 'l') { + e.preventDefault(); + clearConsole(); } + // Up/Down arrows for history when editor is focused + else if (e.target.closest('.ace_editor') && e.ctrlKey) { + if (e.key === 'ArrowUp') { + e.preventDefault(); + navigateHistory('up'); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + navigateHistory('down'); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [runCode, saveCode, clearConsole, navigateHistory]); + + // Memoized console result renderer + const ConsoleResultComponent = ({ result }) => { + const { type, args, sourceLocation, id } = result; + + const getTypeClass = (type) => { + switch (type) { + case LOG_TYPES.ERROR: return styles['console-error']; + case LOG_TYPES.WARN: return styles['console-warn']; + case LOG_TYPES.INFO: return styles['console-info']; + case LOG_TYPES.DEBUG: return styles['console-debug']; + default: return styles['console-log']; + } + }; + + return ( +
+
+
+ {args.map((arg, index) => { + try { + // Validate that the argument is suitable for ReactJson + const isValidForReactJson = (value) => { + // Only use ReactJson for objects and arrays, not primitives + if (value === null || value === undefined) { + return false; // Render as text + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return false; // Render as text + } + + if (typeof value === 'object') { + try { + // Test if it can be JSON serialized without errors + JSON.stringify(value); + // Additional check for reasonable size + const keys = Object.keys(value); + return keys.length < 100 && keys.length > 0; // Must have at least 1 property + } catch { + return false; + } + } + + return false; + }; + + // If the argument is not suitable for ReactJson, render as text + if (!isValidForReactJson(arg)) { + return ( +
+ {String(arg)} +
+ ); + } + + // Use ReactJson for valid objects/arrays + return ( + { + return false; // Don't show the error in the UI + }} + /> + ); + } catch { + return ( +
+ [Error rendering value: {String(arg)}] +
+ ); + } + })} +
+
+ {sourceLocation ? ( + + {sourceLocation.file}:{sourceLocation.line} + + ) : ( + + )} +
+
+
+ ); + }; + + const ConsoleResult = useMemo(() => ConsoleResultComponent, []); + + const renderToolbar = () => { + const runButton = ( + + + {running ? 'Running...' : 'Run'} + + ); + + const editMenu = ( + + executeAndCloseMenu(createNewTab)} + disableMouseDown={true} + /> + executeAndCloseMenu(() => startRenaming(activeTabId, activeTab?.name || ''))} + /> + {tabs.length > 1 && ( + executeAndCloseMenu(() => closeTab(activeTabId))} + /> + )} + {window.localStorage && ( + executeAndCloseMenu(saveCode)} + disabled={saving} + /> + )} + {window.localStorage && savedTabs.find(saved => saved.id === activeTabId) && ( + executeAndCloseMenu(() => deleteTabFromSaved(activeTabId))} + /> + )} + + executeAndCloseMenu(clearConsole)} + /> + + ); + + const tabsMenu = ( + {}}> + {savedTabs.length === 0 ? ( + + ) : ( + savedTabs + .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically by name + .map(savedTab => { + const isOpen = tabs.find(openTab => openTab.id === savedTab.id); + + return ( + + {isOpen && ( + + )} + {savedTab.name} + + } + onClick={() => { + if (isOpen) { + closeTab(savedTab.id); + } else { + reopenTab(savedTab); + } + }} + disableMouseDown={true} + /> + ); + }) + )} + + ); + + return ( + + {runButton} +
+ {editMenu} +
+ {tabsMenu} + + ); + }; + + // Helper function to check if a tab has unsaved changes + const hasUnsavedChanges = useCallback((tab) => { + // Get current content for the tab + let currentContent = ''; + if (tab.id === activeTabId && editorRef.current) { + // For active tab, get content from editor + currentContent = editorRef.current.value; + } else { + // For inactive tabs, use stored code + currentContent = tab.code; } - } - render() { - const { results, running, saving, savingState } = this.state; + // Find the saved version of this tab + const savedTab = savedTabs.find(saved => saved.id === tab.id); + + if (!savedTab) { + // If tab was never saved, it has unsaved changes if it has any content + return currentContent.trim() !== ''; + } + + // Compare current content with saved content + return currentContent !== savedTab.code; + }, [activeTabId, savedTabs]); + + // Effect to periodically check for editor changes and trigger re-renders + useEffect(() => { + const interval = setInterval(() => { + // Force a re-render to update unsaved change indicators + setForceUpdate({}); + }, 1000); // Check every second + + return () => clearInterval(interval); + }, []); + + const renderTabs = () => { + return ( +
+
+ {tabs.map(tab => ( +
switchTab(tab.id)} + draggable={true} + onDragStart={(e) => handleDragStart(e, tab.id)} + onDragOver={(e) => handleDragOver(e, tab.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, tab.id)} + onDragEnd={handleDragEnd} + > + {renamingTabId === tab.id ? ( + setRenamingValue(e.target.value)} + onBlur={confirmRenaming} + onKeyDown={(e) => { + if (e.key === 'Enter') { + confirmRenaming(); + } else if (e.key === 'Escape') { + cancelRenaming(); + } + }} + onClick={(e) => e.stopPropagation()} + className={styles['tab-rename-input']} + /> + ) : ( + { + e.stopPropagation(); + startRenaming(tab.id, tab.name); + }} + style={{ display: 'flex', alignItems: 'center' }} + > + {hasUnsavedChanges(tab) && ( + + )} + {tab.name} + + )} + {tabs.length > 1 && ( + + )} +
+ ))} + +
+
+ ); + }; - return React.cloneElement( -
- -
+ return ( +
+ {renderToolbar()} +
+
+ {renderTabs()} (this.editor = editor)} + defaultValue={activeTab?.code || DEFAULT_CODE_EDITOR_VALUE} + ref={editorRef} + fontSize={14} + theme="monokai" /> -
-
-

Console

-
-
-
- {window.localStorage && ( - this.saveCode()} - progress={saving} - /> - )} -
-
-
-
-
- {results.map(({ log, name }, i) => ( - - ))} -
+
+ 💡 Shortcuts: + Ctrl/Cmd + Enter to run,{' '} + Ctrl/Cmd + S to save,{' '} + Ctrl/Cmd + L to clear console,{' '} + Ctrl + ↑/↓ for history
+
+
+
+ {results.length === 0 ? ( +
+ Console output will appear here... +
+ Run your code to see results +
+ ) : ( + results.map(result => ( + + )) + )} +
+
- ); - } +
+ ); } diff --git a/src/dashboard/Data/Playground/Playground.scss b/src/dashboard/Data/Playground/Playground.scss index 6af6efbf0..9c758e56e 100644 --- a/src/dashboard/Data/Playground/Playground.scss +++ b/src/dashboard/Data/Playground/Playground.scss @@ -2,13 +2,256 @@ padding-top: 96px; background-color: #002b36; height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; } -.console-ctn { +.playground-content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.editor-section { + position: relative; + border-bottom: 1px solid #169cee; + overflow: hidden; + display: flex; + flex-direction: column; + + /* Ensure the code editor fills the container */ + .ace_editor { + height: 100% !important; + } + + /* Ensure the react-ace wrapper also fills the container */ + & > div:not(.tab-bar) { + height: 100% !important; + flex: 1; + } +} + +.tab-bar { + display: flex; + background: #073642; + border-bottom: 1px solid #169cee; + padding: 0 4px; + align-items: flex-end; + flex-shrink: 0; + overflow-x: auto; + overflow-y: hidden; + + /* Custom scrollbar for tab bar */ + &::-webkit-scrollbar { + height: 4px; + } + + &::-webkit-scrollbar-track { + background: #073642; + } + + &::-webkit-scrollbar-thumb { + background: #586e75; + border-radius: 2px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #657b83; + } +} + +.tab-container { display: flex; align-items: center; - justify-content: stretch; + flex-shrink: 0; + min-width: min-content; +} + +.tab { + display: flex; + align-items: center; + padding: 4px 4px 4px 12px; + margin-right: 4px; + margin-bottom: -1px; + background: #2C2C35; + border: 1px solid #586e75; + border-bottom: none; + border-radius: 4px 4px 0 0; + cursor: pointer; + font-size: 12px; + color: #93a1a1; + transition: all 0.2s ease; + min-width: 80px; + max-width: 200px; + flex-shrink: 0; + white-space: nowrap; + + &:hover { + background: #073642; + color: #fdf6e3; + } + + &.tab-active { + background: #169cee; + color: #fdf6e3; + border-color: #169cee; + } + + /* Drag and drop styles */ + &.tab-dragging { + opacity: 0.5; + z-index: 1000; + cursor: grabbing; + } + + &.tab-drag-over { + border-left: 3px solid #d33682; + background: rgba(211, 54, 130, 0.1); + } + + /* Make tabs draggable */ + &[draggable="true"] { + cursor: grab; + + &:active { + cursor: grabbing; + } + } +} + +.tab-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 12px; + cursor: pointer; + + &:hover { + text-decoration: underline; + text-decoration-style: dotted; + } +} + +.tab-rename-input { + flex: 1; + background: #fdf6e3; + border: 1px solid #169cee; + color: #002b36; + padding: 2px 4px; + margin: 0; + font-size: 12px; + font-family: inherit; + border-radius: 2px; + outline: none; + margin-right: 4px; + + &:focus { + box-shadow: 0 0 0 2px rgba(22, 156, 238, 0.3); + } +} + +.tab-close { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + font-size: 14px; + line-height: 1; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +} + +.tab-new { + background: none; + border: 1px solid #586e75; + color: #93a1a1; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + margin-left: 4px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &:hover { + background: #073642; + color: #fdf6e3; + border-color: #169cee; + } +} + +.resize-handle { + height: 4px; + background: #169cee; + cursor: ns-resize; + position: relative; + user-select: none; + + &:hover { + background: #2aa198; + } + + &:active { + background: #cb4b16; + } + + &::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 2px; + background: rgba(255, 255, 255, 0.8); + border-radius: 1px; + } +} + +.editor-help { + display: none; + position: absolute; + bottom: 8px; + right: 12px; + background: rgba(0, 43, 54, 0.9); + color: #93a1a1; + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-family: monospace; + border: 1px solid #073642; + + kbd { + background: #073642; + color: #839496; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + border: 1px solid #586e75; + margin: 0 2px; + } +} + +.console-ctn { + display: flex; flex-direction: column; + overflow: hidden; & h3 { height: 28px; @@ -16,32 +259,193 @@ font-size: 16px; font-weight: 700; color: white; + margin: 0; } & > header { - flex: 1; - padding: 0 0 0 10px; + flex: none; + padding: 12px 16px; background-color: #169cee; display: flex; align-items: center; justify-content: space-between; width: 100%; + box-sizing: border-box; + position: sticky; + top: 0; + z-index: 10; } & > section { + flex: 1; width: 100%; + overflow-y: auto; + background-color: #110D11; + } +} + +.console-output { + padding: 2px 4px; + overflow-y: auto; + background-color: #110D11; + height: 100%; + font-size: 12px; + line-height: 1.2; +} + +.console-empty { + text-align: center; + color: #586e75; + padding: 8px 6px; + font-style: italic; + font-size: 11px; + line-height: 1.2; + + small { + color: #657b83; + font-size: 10px; + } +} + +.console-entry { + margin-bottom: 2px; + padding: 4px 8px; + border-radius: 4px; + + &.console-error { + color: #dc322f; + background-color: rgba(220, 50, 47, 0.05); + } + + &.console-warn { + color: #b58900; + background-color: rgba(181, 137, 0, 0.05); + } + + &.console-info { + color: #268bd2; + } + + &.console-debug { + color: #6c71c4; + } + + &.console-log { + color: #93a1a1; + } +} + +.console-content { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.console-output-content { + flex: 1; + min-width: 0; // Allow content to shrink +} + +.console-source { + flex-shrink: 0; + font-family: monospace; + font-size: 10px; + color: #657b83; + text-align: right; + min-width: 80px; + padding-left: 6px; + border-left: 1px solid #073642; + line-height: 1.2; + + &:hover { + color: #839496; } } +.console-source-unknown { + opacity: 0.5; +} + +.console-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 12px; + font-family: monospace; + color: #93a1a1; +} + +.console-icon { + font-size: 14px; +} + +.console-type { + font-weight: 600; + text-transform: uppercase; + color: #839496; +} + +.console-timestamp { + color: #657b83; + margin-left: auto; + font-size: 11px; +} + .buttons-ctn { display: flex; justify-content: flex-end; - padding: 15px; align-items: center; & > div { display: flex; justify-content: flex-end; - width: 25%; + align-items: center; + gap: 8px; } } + +/* Custom scrollbar for console output */ +.console-output::-webkit-scrollbar { + width: 8px; +} + +.console-output::-webkit-scrollbar-track { + background: #073642; +} + +.console-output::-webkit-scrollbar-thumb { + background: #586e75; + border-radius: 4px; +} + +.console-output::-webkit-scrollbar-thumb:hover { + background: #657b83; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .editor-help { + display: none; + } + + .console-header { + font-size: 11px; + gap: 6px; + } + + .resize-handle { + height: 12px; /* Make it larger on mobile for easier touch interaction */ + } +} + +/* Prevent text selection during resize */ +.playground-content.resizing { + user-select: none; +} + +/* Enhanced resize handle hover state */ +.resize-handle:hover::before { + width: 60px; + background: rgba(255, 255, 255, 1); +} diff --git a/src/lib/ScriptManager.js b/src/lib/ScriptManager.js new file mode 100644 index 000000000..4bbb4bcb6 --- /dev/null +++ b/src/lib/ScriptManager.js @@ -0,0 +1,422 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ + +import ServerConfigStorage from './ServerConfigStorage'; +import { prefersServerStorage, setStoragePreference } from './StoragePreferences'; + +const VERSION = 1; + +/** + * Script Manager for handling playground script storage with server-side storage support + */ +export default class ScriptManager { + constructor(app) { + this.app = app; + this.serverStorage = new ServerConfigStorage(app); + } + + /** + * Gets scripts from either server or local storage based on configuration and user preference + * @param {string} appId - The application ID + * @returns {Promise} Array of scripts + */ + async getScripts(appId) { + // Check if server storage is enabled and user prefers it + if (this.serverStorage.isServerConfigEnabled() && prefersServerStorage(appId)) { + try { + const serverScripts = await this._getScriptsFromServer(appId); + // Always return server scripts (even if empty) when server storage is preferred + return serverScripts || []; + } catch (error) { + console.error('Failed to get scripts from server:', error); + // When server storage is preferred, return empty array instead of falling back to local + return []; + } + } + + // Use local storage when server storage is not preferred + let localScripts = this._getScriptsFromLocal(appId); + + // Always check for legacy single-script format and add it as a new unsaved tab + const legacyScript = this._getScriptFromLegacySingleFormat(); + if (legacyScript && legacyScript.length > 0) { + // If we have existing scripts, add the legacy script to them + if (localScripts && localScripts.length > 0) { + localScripts = [...localScripts, ...legacyScript]; + } else { + // If no existing scripts, use the legacy script + localScripts = legacyScript; + } + } + + return localScripts || []; + } + + /** + * Gets only the scripts that should be open (have an order property) + * @param {string} appId - The application ID + * @returns {Promise} Array of scripts that should be open, sorted by order + */ + async getOpenScripts(appId) { + const allScripts = await this.getScripts(appId); + return allScripts + .filter(script => script.order !== undefined && script.order !== null) + .sort((a, b) => a.order - b.order); + } + + /** + * Gets all saved scripts (including closed ones) + * @param {string} appId - The application ID + * @returns {Promise} Array of all saved scripts + */ + async getAllSavedScripts(appId) { + const allScripts = await this.getScripts(appId); + return allScripts.filter(script => script.saved !== false); + } + + /** + * Opens a script by setting its order property + * @param {string} appId - The application ID + * @param {number} scriptId - The script ID to open + * @param {number} order - The order position for the tab + * @returns {Promise} + */ + async openScript(appId, scriptId, order) { + const allScripts = await this.getScripts(appId); + const updatedScripts = allScripts.map(script => + script.id === scriptId + ? { ...script, order } + : script + ); + await this.saveScripts(appId, updatedScripts); + } + + /** + * Closes a script by removing its order property + * @param {string} appId - The application ID + * @param {number} scriptId - The script ID to close + * @returns {Promise} + */ + async closeScript(appId, scriptId) { + const allScripts = await this.getScripts(appId); + const updatedScripts = allScripts.map(script => + script.id === scriptId + ? { ...script, order: undefined } + : script + ); + await this.saveScripts(appId, updatedScripts); + } + + /** + * Updates the order of open scripts + * @param {string} appId - The application ID + * @param {Array} openScripts - Array of scripts with their new order + * @returns {Promise} + */ + async updateScriptOrder(appId, openScripts) { + const allScripts = await this.getScripts(appId); + const openScriptIds = openScripts.map(script => script.id); + + const updatedScripts = allScripts.map(script => { + const openScript = openScripts.find(os => os.id === script.id); + if (openScript) { + return { ...script, ...openScript }; + } else if (openScriptIds.includes(script.id)) { + // Script was previously open but not in the new list, close it + return { ...script, order: undefined }; + } + return script; + }); + + await this.saveScripts(appId, updatedScripts); + } + + /** + * Completely deletes a script from storage + * @param {string} appId - The application ID + * @param {number} scriptId - The script ID to delete + * @returns {Promise} + */ + async deleteScript(appId, scriptId) { + const allScripts = await this.getScripts(appId); + const updatedScripts = allScripts.filter(script => script.id !== scriptId); + await this.saveScripts(appId, updatedScripts); + } + + /** + * Saves scripts to either server or local storage based on configuration and user preference + * @param {string} appId - The application ID + * @param {Array} scripts - Array of scripts to save + * @returns {Promise} + */ + async saveScripts(appId, scripts) { + // Check if server storage is enabled and user prefers it + if (this.serverStorage.isServerConfigEnabled() && prefersServerStorage(appId)) { + // Use server storage - no fallback to local + return await this._saveScriptsToServer(appId, scripts); + } + + // Use local storage when server storage is not preferred + return this._saveScriptsToLocal(appId, scripts); + } + + /** + * Migrates scripts from local storage to server storage + * @param {string} appId - The application ID + * @returns {Promise<{success: boolean, scriptCount: number}>} + */ + async migrateToServer(appId) { + if (!this.serverStorage.isServerConfigEnabled()) { + throw new Error('Server configuration is not enabled for this app'); + } + + // Get scripts from local storage only (legacy scripts are handled by getScripts as unsaved tabs) + const localScripts = this._getScriptsFromLocal(appId); + + if (!localScripts || localScripts.length === 0) { + return { success: true, scriptCount: 0 }; + } + + try { + await this._saveScriptsToServer(appId, localScripts); + return { success: true, scriptCount: localScripts.length }; + } catch (error) { + console.error('Failed to migrate scripts to server:', error); + throw error; + } + } + + /** + * Deletes scripts from local storage + * @param {string} appId - The application ID + * @returns {boolean} True if deletion was successful + */ + deleteFromBrowser(appId) { + try { + // Remove from new format + localStorage.removeItem(this._getLocalPath(appId)); + return true; + } catch (error) { + console.error('Failed to delete scripts from browser:', error); + return false; + } + } + + /** + * Sets the storage preference for the app + * @param {string} appId - The application ID + * @param {string} preference - The storage preference ('local' or 'server') + */ + setStoragePreference(appId, preference) { + setStoragePreference(appId, preference); + } + + /** + * Gets the current storage preference for the app + * @param {string} appId - The application ID + * @returns {string} The storage preference ('local' or 'server') + */ + getStoragePreference(appId) { + return prefersServerStorage(appId) ? 'server' : 'local'; + } + + /** + * Checks if server configuration is enabled for this app + * @returns {boolean} True if server config is enabled + */ + isServerConfigEnabled() { + return this.serverStorage.isServerConfigEnabled(); + } + + /** + * Gets scripts from server storage + * @private + */ + async _getScriptsFromServer(appId) { + try { + const scriptConfigs = await this.serverStorage.getConfigsByPrefix('console.js.script.', appId); + const scripts = []; + + Object.entries(scriptConfigs).forEach(([key, config]) => { + if (config && typeof config === 'object') { + // Extract script ID from key (console.js.script.{SCRIPT_ID}) + const scriptId = key.replace('console.js.script.', ''); + + scripts.push({ + id: parseInt(scriptId, 10), + ...config + }); + } + }); + + return scripts; + } catch (error) { + console.error('Failed to get scripts from server:', error); + return []; + } + } + + /** + * Saves scripts to server storage + * @private + */ + async _saveScriptsToServer(appId, scripts) { + try { + // First, get existing scripts from server to know which ones to delete + const existingScriptConfigs = await this.serverStorage.getConfigsByPrefix('console.js.script.', appId); + const existingScriptIds = Object.keys(existingScriptConfigs).map(key => + key.replace('console.js.script.', '') + ); + + // Delete scripts that are no longer in the new scripts array + const newScriptIds = scripts.map(script => script.id.toString()); + const scriptsToDelete = existingScriptIds.filter(id => !newScriptIds.includes(id)); + + await Promise.all( + scriptsToDelete.map(id => + this.serverStorage.deleteConfig(`console.js.script.${id}`, appId) + ) + ); + + // Save or update current scripts + await Promise.all( + scripts.map(script => { + const scriptConfig = { ...script }; + delete scriptConfig.id; // Don't store ID in the config itself + + // Remove null and undefined values to keep the storage clean + Object.keys(scriptConfig).forEach(key => { + if (scriptConfig[key] === null || scriptConfig[key] === undefined) { + delete scriptConfig[key]; + } + }); + + return this.serverStorage.setConfig( + `console.js.script.${script.id}`, + scriptConfig, + appId + ); + }) + ); + } catch (error) { + console.error('Failed to save scripts to server:', error); + throw error; + } + } + + /** + * Gets scripts from local storage (original implementation) + * @private + */ + _getScriptsFromLocal(appId) { + let entry; + try { + entry = localStorage.getItem(this._getLocalPath(appId)) || '[]'; + } catch { + entry = '[]'; + } + try { + return JSON.parse(entry); + } catch { + return []; + } + } + + /** + * Gets script from the legacy single-script format + * @private + */ + _getScriptFromLegacySingleFormat() { + try { + const legacyCode = localStorage.getItem('parse-dashboard-playground-code'); + + if (legacyCode && legacyCode.trim()) { + // Create a script with the legacy code, marked as unsaved + const script = { + id: this._generateScriptId({ name: 'Legacy Script', code: legacyCode }), + name: 'Legacy Script', + code: legacyCode, + saved: false, // Mark as unsaved so user can choose to save it + lastModified: Date.now() + }; + + // Clean up the old storage key immediately after reading + localStorage.removeItem('parse-dashboard-playground-code'); + + return [script]; + } + } catch { + // Ignore errors + } + return []; + } + + /** + * Saves scripts to local storage (original implementation) + * @private + */ + _saveScriptsToLocal(appId, scripts) { + try { + localStorage.setItem(this._getLocalPath(appId), JSON.stringify(scripts)); + } catch { + // ignore write errors + } + } + + /** + * Gets the local storage path for scripts + * @private + */ + _getLocalPath(appId) { + return `ParseDashboard:${VERSION}:${appId}:Scripts`; + } + + /** + * Generates a unique ID for a script + * @private + */ + _generateScriptId(script) { + // Use a hash of the script name and code as a fallback ID + const str = `${script.name || 'script'}-${script.code || ''}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); + } +} + +// Legacy API compatibility - these functions will work with local storage only +// for backward compatibility +export function getScripts(appId) { + let entry; + try { + entry = localStorage.getItem(path(appId)) || '[]'; + } catch { + entry = '[]'; + } + try { + return JSON.parse(entry); + } catch { + return []; + } +} + +export function saveScripts(appId, scripts) { + try { + localStorage.setItem(path(appId), JSON.stringify(scripts)); + } catch { + // ignore write errors + } +} + +function path(appId) { + return `ParseDashboard:${VERSION}:${appId}:Scripts`; +} From a552d1f4257b39da678ec2801c244432704c07ff Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 4 Aug 2025 13:20:02 +0000 Subject: [PATCH 05/13] chore(release): 7.4.0-alpha.2 [skip ci] # [7.4.0-alpha.2](https://github.com/parse-community/parse-dashboard/compare/7.4.0-alpha.1...7.4.0-alpha.2) (2025-08-04) ### Features * Modernize JavaScript console with tabs and server-side storage of scripts ([#2962](https://github.com/parse-community/parse-dashboard/issues/2962)) ([6e0c7f2](https://github.com/parse-community/parse-dashboard/commit/6e0c7f25b16fdfc0a34123359a8512c26252f269)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 2703b8aeb..d5969b0fb 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [7.4.0-alpha.2](https://github.com/parse-community/parse-dashboard/compare/7.4.0-alpha.1...7.4.0-alpha.2) (2025-08-04) + + +### Features + +* Modernize JavaScript console with tabs and server-side storage of scripts ([#2962](https://github.com/parse-community/parse-dashboard/issues/2962)) ([6e0c7f2](https://github.com/parse-community/parse-dashboard/commit/6e0c7f25b16fdfc0a34123359a8512c26252f269)) + # [7.4.0-alpha.1](https://github.com/parse-community/parse-dashboard/compare/7.3.0...7.4.0-alpha.1) (2025-08-03) diff --git a/package-lock.json b/package-lock.json index 418c37421..5c4bb8870 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-dashboard", - "version": "7.4.0-alpha.1", + "version": "7.4.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parse-dashboard", - "version": "7.4.0-alpha.1", + "version": "7.4.0-alpha.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@babel/runtime": "7.27.4", diff --git a/package.json b/package.json index 6130da56f..a67ffceb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-dashboard", - "version": "7.4.0-alpha.1", + "version": "7.4.0-alpha.2", "repository": { "type": "git", "url": "https://github.com/parse-community/parse-dashboard" From 8c8d0849521eccd23e590c57ca91069bb32c5035 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 4 Aug 2025 21:58:40 +0200 Subject: [PATCH 06/13] fix: Legacy script in JavaScript console not imported to modern console (#2963) --- .../Data/Playground/Playground.react.js | 185 +++++++++++++++--- .../DashboardSettings.react.js | 33 +++- src/lib/ScriptManager.js | 66 +++++-- 3 files changed, 229 insertions(+), 55 deletions(-) diff --git a/src/dashboard/Data/Playground/Playground.react.js b/src/dashboard/Data/Playground/Playground.react.js index 8ba356101..459cc684f 100644 --- a/src/dashboard/Data/Playground/Playground.react.js +++ b/src/dashboard/Data/Playground/Playground.react.js @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect, useContext, useCallback, useMemo } from 'react'; import ReactJson from 'react-json-view'; import Parse from 'parse'; +import { useBeforeUnload } from 'react-router-dom'; import CodeEditor from 'components/CodeEditor/CodeEditor.react'; import Toolbar from 'components/Toolbar/Toolbar.react'; @@ -176,11 +177,11 @@ export default function Playground() { const containerRef = useRef(null); // Tab management state + const initialTabId = useMemo(() => crypto.randomUUID(), []); const [tabs, setTabs] = useState([ - { id: 1, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE } + { id: initialTabId, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE } ]); - const [activeTabId, setActiveTabId] = useState(1); - const [nextTabId, setNextTabId] = useState(2); + const [activeTabId, setActiveTabId] = useState(initialTabId); const [renamingTabId, setRenamingTabId] = useState(null); const [renamingValue, setRenamingValue] = useState(''); const [savedTabs, setSavedTabs] = useState([]); // All saved tabs including closed ones @@ -235,8 +236,6 @@ export default function Playground() { if (tabsToOpen.length > 0) { setTabs(tabsToOpen); - const maxId = Math.max(...allScripts.map(tab => tab.id)); - setNextTabId(maxId + 1); // Set active tab to the first one setActiveTabId(tabsToOpen[0].id); @@ -249,8 +248,6 @@ export default function Playground() { const firstScript = { ...allScripts[0], order: 0 }; setTabs([firstScript]); setActiveTabId(firstScript.id); - const maxId = Math.max(...allScripts.map(tab => tab.id)); - setNextTabId(maxId + 1); // Save it as open await scriptManagerRef.current.openScript(context.applicationId, firstScript.id, 0); @@ -258,17 +255,17 @@ export default function Playground() { setSavedTabs(allScripts.filter(script => script.saved !== false)); } else { // Fallback to default tab if no scripts exist - setTabs([{ id: 1, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE, order: 0 }]); - setActiveTabId(1); - setNextTabId(2); + const defaultTabId = crypto.randomUUID(); + setTabs([{ id: defaultTabId, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE, order: 0 }]); + setActiveTabId(defaultTabId); } } } catch (error) { console.warn('Failed to load scripts via ScriptManager:', error); // Fallback to default tab if loading fails - setTabs([{ id: 1, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE, order: 0 }]); - setActiveTabId(1); - setNextTabId(2); + const defaultTabId = crypto.randomUUID(); + setTabs([{ id: defaultTabId, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE, order: 0 }]); + setActiveTabId(defaultTabId); } // Load other data from localStorage @@ -317,18 +314,19 @@ export default function Playground() { // Tab management functions const createNewTab = useCallback(() => { + const newTabId = crypto.randomUUID(); + const tabCount = tabs.length + 1; const newTab = { - id: nextTabId, - name: `Tab ${nextTabId}`, + id: newTabId, + name: `Tab ${tabCount}`, code: '', // Start with empty code instead of default value saved: false, // Mark as unsaved initially order: tabs.length // Assign order as the last position }; const updatedTabs = [...tabs, newTab]; setTabs(updatedTabs); - setActiveTabId(nextTabId); - setNextTabId(nextTabId + 1); - }, [tabs, nextTabId]); + setActiveTabId(newTabId); + }, [tabs]); const closeTab = useCallback(async (tabId) => { if (tabs.length <= 1) { @@ -591,11 +589,6 @@ export default function Playground() { setTabs(updatedTabs); setActiveTabId(savedTab.id); - // Update nextTabId if necessary - if (savedTab.id >= nextTabId) { - setNextTabId(savedTab.id + 1); - } - // Save the open state through ScriptManager if (scriptManagerRef.current && context?.applicationId) { try { @@ -604,7 +597,151 @@ export default function Playground() { console.error('Failed to open script:', error); } } - }, [tabs, nextTabId, switchTab, context?.applicationId]); + }, [tabs, switchTab, context?.applicationId]); + + // Navigation confirmation for unsaved changes + useBeforeUnload( + useCallback( + (event) => { + // Check for unsaved changes across all tabs + let hasChanges = false; + + for (const tab of tabs) { + // Check if tab is marked as unsaved (like legacy scripts) + if (tab.saved === false) { + hasChanges = true; + break; + } + + // Get current content for the tab + let currentContent = ''; + if (tab.id === activeTabId && editorRef.current) { + // For active tab, get content from editor + currentContent = editorRef.current.value; + } else { + // For inactive tabs, use stored code + currentContent = tab.code; + } + + // Find the saved version of this tab + const savedTab = savedTabs.find(saved => saved.id === tab.id); + + if (!savedTab) { + // If tab was never saved, it has unsaved changes if it has any content + if (currentContent.trim() !== '') { + hasChanges = true; + break; + } + } else { + // Compare current content with saved content + if (currentContent !== savedTab.code) { + hasChanges = true; + break; + } + } + } + + if (hasChanges) { + const message = 'You have unsaved changes in your playground tabs. Are you sure you want to leave?'; + event.preventDefault(); + event.returnValue = message; + return message; + } + }, + [tabs, activeTabId, savedTabs] + ) + ); + + // Handle navigation confirmation for internal route changes + useEffect(() => { + const checkForUnsavedChanges = () => { + // Check for unsaved changes across all tabs + for (const tab of tabs) { + // Check if tab is marked as unsaved (like legacy scripts) + if (tab.saved === false) { + return true; + } + + // Get current content for the tab + let currentContent = ''; + if (tab.id === activeTabId && editorRef.current) { + // For active tab, get content from editor + currentContent = editorRef.current.value; + } else { + // For inactive tabs, use stored code + currentContent = tab.code; + } + + // Find the saved version of this tab + const savedTab = savedTabs.find(saved => saved.id === tab.id); + + if (!savedTab) { + // If tab was never saved, it has unsaved changes if it has any content + if (currentContent.trim() !== '') { + return true; + } + } else { + // Compare current content with saved content + if (currentContent !== savedTab.code) { + return true; + } + } + } + return false; + }; + + const handleLinkClick = (event) => { + if (event.defaultPrevented) { + return; + } + if (event.button !== 0) { + return; + } + if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) { + return; + } + + const anchor = event.target.closest('a[href]'); + if (!anchor || anchor.target === '_blank') { + return; + } + + const href = anchor.getAttribute('href'); + if (!href || href === '#') { + return; + } + + // Check if it's an internal navigation (starts with / or #) + if (href.startsWith('/') || href.startsWith('#')) { + if (checkForUnsavedChanges()) { + const message = 'You have unsaved changes in your playground tabs. Are you sure you want to leave?'; + if (!window.confirm(message)) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + }; + + const handlePopState = () => { + if (checkForUnsavedChanges()) { + const message = 'You have unsaved changes in your playground tabs. Are you sure you want to leave?'; + if (!window.confirm(message)) { + window.history.go(1); + } + } + }; + + // Add event listeners + document.addEventListener('click', handleLinkClick, true); + window.addEventListener('popstate', handlePopState); + + // Cleanup event listeners + return () => { + document.removeEventListener('click', handleLinkClick, true); + window.removeEventListener('popstate', handlePopState); + }; + }, [tabs, activeTabId, savedTabs]); // Focus input when starting to rename useEffect(() => { diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js index 4e2cd7b0f..c7d8f11aa 100644 --- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js +++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js @@ -18,6 +18,7 @@ import Notification from 'dashboard/Data/Browser/Notification.react'; import * as ColumnPreferences from 'lib/ColumnPreferences'; import * as ClassPreferences from 'lib/ClassPreferences'; import ViewPreferencesManager from 'lib/ViewPreferencesManager'; +import ScriptManager from 'lib/ScriptManager'; import bcrypt from 'bcryptjs'; import * as OTPAuth from 'otpauth'; import QRCode from 'qrcode'; @@ -28,6 +29,7 @@ export default class DashboardSettings extends DashboardView { this.section = 'App Settings'; this.subsection = 'Dashboard Configuration'; this.viewPreferencesManager = null; + this.scriptManager = null; this.state = { createUserInput: false, @@ -57,12 +59,13 @@ export default class DashboardSettings extends DashboardView { } componentDidMount() { - this.initializeViewPreferencesManager(); + this.initializeManagers(); } - initializeViewPreferencesManager() { + initializeManagers() { if (this.context) { this.viewPreferencesManager = new ViewPreferencesManager(this.context); + this.scriptManager = new ScriptManager(this.context); this.loadStoragePreference(); } } @@ -123,11 +126,18 @@ export default class DashboardSettings extends DashboardView { return; } - const success = this.viewPreferencesManager.deleteFromBrowser(this.context.applicationId); - if (success) { - this.showNote('Successfully deleted views from browser storage.'); + if (!this.scriptManager) { + this.showNote('ScriptManager not initialized'); + return; + } + + const viewsSuccess = this.viewPreferencesManager.deleteFromBrowser(this.context.applicationId); + const scriptsSuccess = this.scriptManager.deleteFromBrowser(this.context.applicationId); + + if (viewsSuccess && scriptsSuccess) { + this.showNote('Successfully deleted dashboard settings from browser storage.'); } else { - this.showNote('Failed to delete views from browser storage.'); + this.showNote('Failed to delete all dashboard settings from browser storage.'); } } @@ -461,13 +471,16 @@ export default class DashboardSettings extends DashboardView { } /> - {this.viewPreferencesManager && this.viewPreferencesManager.isServerConfigEnabled() && ( + {this.viewPreferencesManager && this.scriptManager && this.viewPreferencesManager.isServerConfigEnabled() && (
+
+ Storing dashboard settings on the server rather than locally in the browser storage makes the settings available across devices and browsers. It also prevents them from getting lost when resetting the browser website data. Settings that can be stored on the server are currently Views and JS Console scripts. +
} input={ @@ -487,7 +500,7 @@ export default class DashboardSettings extends DashboardView { label={