diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 4996f4a93a..b06ad69597 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,38 @@ +# [7.4.0-alpha.5](https://github.com/parse-community/parse-dashboard/compare/7.4.0-alpha.4...7.4.0-alpha.5) (2025-08-22) + + +### Features + +* Add info panel setting to auto-load first row on opening new browser tab ([#2972](https://github.com/parse-community/parse-dashboard/issues/2972)) ([020a25d](https://github.com/parse-community/parse-dashboard/commit/020a25dc302e45e947eb5c15a271bb9f0f1211f2)) + +# [7.4.0-alpha.4](https://github.com/parse-community/parse-dashboard/compare/7.4.0-alpha.3...7.4.0-alpha.4) (2025-08-22) + + +### Features + +* Add config parameter name to quick add dialogs in Config page ([#2970](https://github.com/parse-community/parse-dashboard/issues/2970)) ([31988f6](https://github.com/parse-community/parse-dashboard/commit/31988f68cda16856b0c6afb22e4f06321cbcef03)) + +# [7.4.0-alpha.3](https://github.com/parse-community/parse-dashboard/compare/7.4.0-alpha.2...7.4.0-alpha.3) (2025-08-04) + + +### Bug Fixes + +* Legacy script in JavaScript console not imported to modern console ([#2963](https://github.com/parse-community/parse-dashboard/issues/2963)) ([8c8d084](https://github.com/parse-community/parse-dashboard/commit/8c8d0849521eccd23e590c57ca91069bb32c5035)) + +# [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) + + +### 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 c4648074d2..85830ec49c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "parse-dashboard", - "version": "7.3.0", + "version": "7.4.0-alpha.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parse-dashboard", - "version": "7.3.0", + "version": "7.4.0-alpha.5", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@babel/runtime": "7.27.4", - "@babel/runtime-corejs3": "7.27.4", + "@babel/runtime-corejs3": "7.28.3", "bcryptjs": "3.0.2", "body-parser": "2.2.0", "commander": "13.1.0", @@ -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", @@ -2106,12 +2106,12 @@ } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.4.tgz", - "integrity": "sha512-H7QhL0ucCGOObsUETNbB2PuzF4gAvN8p32P6r91bX7M/hk4bx+3yz2hTwHL9d/Efzwu1upeb4/cd7oSxCzup3w==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.3.tgz", + "integrity": "sha512-LKYxD2CIfocUFNREQ1yk+dW+8OH8CRqmgatBZYXb+XhuObO8wsDpEoCNri5bKld9cnj8xukqZjxSX8p1YiRF8Q==", "license": "MIT", "dependencies": { - "core-js-pure": "^3.30.2" + "core-js-pure": "^3.43.0" }, "engines": { "node": ">=6.9.0" @@ -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" @@ -9630,9 +9630,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.41.0.tgz", - "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==", + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.45.0.tgz", + "integrity": "sha512-OtwjqcDpY2X/eIIg1ol/n0y/X8A9foliaNt1dSK0gV3J2/zw+89FcNG3mPK+N8YWts4ZFUPxnrAzsxs/lf8yDA==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -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 99b357c99c..01ee9d9a89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-dashboard", - "version": "7.3.0", + "version": "7.4.0-alpha.5", "repository": { "type": "git", "url": "https://github.com/parse-community/parse-dashboard" @@ -36,7 +36,7 @@ ], "dependencies": { "@babel/runtime": "7.27.4", - "@babel/runtime-corejs3": "7.27.4", + "@babel/runtime-corejs3": "7.28.3", "bcryptjs": "3.0.2", "body-parser": "2.2.0", "commander": "13.1.0", @@ -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", diff --git a/src/components/BrowserMenu/MenuItem.react.js b/src/components/BrowserMenu/MenuItem.react.js index 84eedf1ad0..2cd09381a3 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 e8554fe7e3..4a3c75521d 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/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index a311ce5188..9a322592d8 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -85,6 +85,8 @@ const BrowserToolbar = ({ appName, scrollToTop, toggleScrollToTop, + autoLoadFirstRow, + toggleAutoLoadFirstRow, }) => { const selectionLength = Object.keys(selection).length; const isPendingEditCloneRows = editCloneRows && editCloneRows.length > 0; @@ -388,6 +390,25 @@ const BrowserToolbar = ({ toggleScrollToTop(); }} /> + + {autoLoadFirstRow && ( + + )} + Auto-load first row + + } + onClick={() => { + toggleAutoLoadFirstRow(); + }} + />
diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 0c1f260f3d..ff1127d46d 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -22,6 +22,7 @@ import AggregationPanel from '../../../components/AggregationPanel/AggregationPa const BROWSER_SHOW_ROW_NUMBER = 'browserShowRowNumber'; const AGGREGATION_PANEL_VISIBLE = 'aggregationPanelVisible'; const BROWSER_SCROLL_TO_TOP = 'browserScrollToTop'; +const AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW = 'aggregationPanelAutoLoadFirstRow'; function formatValueForCopy(value, type) { if (value === undefined) { @@ -86,6 +87,8 @@ export default class DataBrowser extends React.Component { window.localStorage?.getItem(AGGREGATION_PANEL_VISIBLE) === 'true'; const storedScrollToTop = window.localStorage?.getItem(BROWSER_SCROLL_TO_TOP) !== 'false'; + const storedAutoLoadFirstRow = + window.localStorage?.getItem(AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW) === 'true'; const hasAggregation = props.classwiseCloudFunctions?.[ `${props.app.applicationId}${props.appName}` @@ -111,6 +114,7 @@ export default class DataBrowser extends React.Component { frozenColumnIndex: -1, showRowNumber: storedRowNumber, scrollToTop: storedScrollToTop, + autoLoadFirstRow: storedAutoLoadFirstRow, prefetchCache: {}, selectionHistory: [], }; @@ -135,6 +139,7 @@ export default class DataBrowser extends React.Component { this.unfreezeColumns = this.unfreezeColumns.bind(this); this.setShowRowNumber = this.setShowRowNumber.bind(this); this.toggleScrollToTop = this.toggleScrollToTop.bind(this); + this.toggleAutoLoadFirstRow = this.toggleAutoLoadFirstRow.bind(this); this.handleCellClick = this.handleCellClick.bind(this); this.saveOrderTimeout = null; this.aggregationPanelRef = React.createRef(); @@ -222,6 +227,29 @@ export default class DataBrowser extends React.Component { } } + // Auto-load first row if enabled and conditions are met + if ( + this.state.autoLoadFirstRow && + this.state.isPanelVisible && + this.props.data && + this.props.data.length > 0 && + !this.state.selectedObjectId && + ((!prevProps.data || prevProps.data.length === 0) || + prevProps.className !== this.props.className || + prevState.isPanelVisible !== this.state.isPanelVisible) + ) { + const firstRowObjectId = this.props.data[0].id; + this.setShowAggregatedData(true); + this.setSelectedObjectId(firstRowObjectId); + // Also set the current cell to the first cell of the first row + this.setCurrent({ row: 0, col: 0 }); + this.handleCallCloudFunction( + firstRowObjectId, + this.props.className, + this.props.app.applicationId + ); + } + if ( (this.props.AggregationPanelData !== prevProps.AggregationPanelData || this.state.selectedObjectId !== prevState.selectedObjectId) && @@ -288,6 +316,25 @@ export default class DataBrowser extends React.Component { } } + // Auto-load first row when opening panel if enabled and no row is selected + if ( + newVisibility && + this.state.autoLoadFirstRow && + !this.state.selectedObjectId && + this.props.data && + this.props.data.length > 0 + ) { + const firstRowObjectId = this.props.data[0].id; + this.setShowAggregatedData(true); + this.setSelectedObjectId(firstRowObjectId); + this.setCurrent({ row: 0, col: 0 }); + this.handleCallCloudFunction( + firstRowObjectId, + this.props.className, + this.props.app.applicationId + ); + } + if (!newVisibility && this.state.selectedObjectId) { if (this.props.errorAggregatedData != {}) { this.props.setErrorAggregatedData({}); @@ -694,6 +741,14 @@ export default class DataBrowser extends React.Component { }); } + toggleAutoLoadFirstRow() { + this.setState(prevState => { + const newAutoLoadFirstRow = !prevState.autoLoadFirstRow; + window.localStorage?.setItem(AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW, newAutoLoadFirstRow); + return { autoLoadFirstRow: newAutoLoadFirstRow }; + }); + } + getPrefetchSettings() { const config = this.props.classwiseCloudFunctions?.[ @@ -994,6 +1049,8 @@ export default class DataBrowser extends React.Component { appName={this.props.appName} scrollToTop={this.state.scrollToTop} toggleScrollToTop={this.toggleScrollToTop} + autoLoadFirstRow={this.state.autoLoadFirstRow} + toggleAutoLoadFirstRow={this.toggleAutoLoadFirstRow} {...other} /> diff --git a/src/dashboard/Data/Config/AddArrayEntryDialog.react.js b/src/dashboard/Data/Config/AddArrayEntryDialog.react.js index be1b392986..bedde01781 100644 --- a/src/dashboard/Data/Config/AddArrayEntryDialog.react.js +++ b/src/dashboard/Data/Config/AddArrayEntryDialog.react.js @@ -85,6 +85,7 @@ export default class AddArrayEntryDialog extends React.Component { } render() { + const param = this.props.param; const confirmDisabled = this.state.value === '' || (this.state.showMismatchRow && !this.state.mismatchConfirmed); @@ -93,7 +94,8 @@ export default class AddArrayEntryDialog extends React.Component { ); } @@ -575,7 +576,7 @@ class Config extends TableView { ); } - this.showNote('Entry added'); + this.showNote(`Entry added to ${param}`); } catch (e) { this.showNote(`Failed to add entry: ${e.message}`, true); } finally { diff --git a/src/dashboard/Data/Playground/Playground.react.js b/src/dashboard/Data/Playground/Playground.react.js index b741d2dd7a..459cc684f1 100644 --- a/src/dashboard/Data/Playground/Playground.react.js +++ b/src/dashboard/Data/Playground/Playground.react.js @@ -1,12 +1,17 @@ -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 { useBeforeUnload } from 'react-router-dom'; 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 +20,955 @@ 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 initialTabId = useMemo(() => crypto.randomUUID(), []); + const [tabs, setTabs] = useState([ + { id: initialTabId, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE } + ]); + const [activeTabId, setActiveTabId] = useState(initialTabId); + 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); + + // 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); + + // 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 + 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 + const defaultTabId = crypto.randomUUID(); + setTabs([{ id: defaultTabId, name: 'Tab 1', code: DEFAULT_CODE_EDITOR_VALUE, order: 0 }]); + setActiveTabId(defaultTabId); + } + + // 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 newTabId = crypto.randomUUID(); + const tabCount = tabs.length + 1; + const newTab = { + 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(newTabId); + }, [tabs]); + + 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); + + // 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, 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(() => { + 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; + } - async runCode() { - const [originalConsoleLog, originalConsoleError] = this.overrideConsole(); + 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); + } + + 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 +976,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); + + // 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); + + if (existingIndex >= 0) { + // Update existing saved tab + updatedSavedTabs[existingIndex] = { ...currentTab }; + } else { + // Add new tab to saved tabs + updatedSavedTabs.push({ ...currentTab }); + } - window.localStorage.setItem(this.localKey, code); - this.setState({ - saving: false, - savingState: SaveButton.States.SUCCEEDED, - }); + setSavedTabs(updatedSavedTabs); + } - setTimeout(() => this.setState({ savingState: SaveButton.States.WAITING }), 3000); + // 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); + + if (newIndex === -1) { + // Restore to empty or current content + return; + } + + editorRef.current.value = history[newIndex]; + }, [history, historyIndex]); - componentDidMount() { - if (window.localStorage) { - const initialCode = window.localStorage.getItem(this.localKey); - if (initialCode) { - this.editor.value = initialCode; + // 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 React.cloneElement( -
- -
+ 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 ( +
+ {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 6af6efbf0b..9c758e56e8 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/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js index 1f7ddcd2d5..88f0b98a24 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 25b657b1e9..c7d8f11aaf 100644 --- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js +++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js @@ -17,6 +17,8 @@ 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 ScriptManager from 'lib/ScriptManager'; import bcrypt from 'bcryptjs'; import * as OTPAuth from 'otpauth'; import QRCode from 'qrcode'; @@ -26,6 +28,8 @@ export default class DashboardSettings extends DashboardView { super(); this.section = 'App Settings'; this.subsection = 'Dashboard Configuration'; + this.viewPreferencesManager = null; + this.scriptManager = null; this.state = { createUserInput: false, @@ -39,6 +43,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 +58,89 @@ export default class DashboardSettings extends DashboardView { }; } + componentDidMount() { + this.initializeManagers(); + } + + initializeManagers() { + if (this.context) { + this.viewPreferencesManager = new ViewPreferencesManager(this.context); + this.scriptManager = new ScriptManager(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; + } + + 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 all dashboard settings from browser storage.'); + } + } + getColumns() { const data = ColumnPreferences.getAllPreferences(this.context.applicationId); this.setState({ @@ -382,6 +471,64 @@ export default class DashboardSettings extends DashboardView { } /> + {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={ + 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 18e4ab02bc..318c07fe87 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/ScriptManager.js b/src/lib/ScriptManager.js new file mode 100644 index 0000000000..3c9649e7d6 --- /dev/null +++ b/src/lib/ScriptManager.js @@ -0,0 +1,446 @@ +/* + * 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) { + // Check if a script with the same code already exists to prevent duplicates + const legacyScriptData = legacyScript[0]; + const existingScript = (localScripts || []).find(script => + script.code === legacyScriptData.code && script.name === 'Legacy Script' + ); + + if (!existingScript) { + // Assign order property to automatically open the legacy script as a tab + // Use order 0 to make it the first tab + legacyScriptData.order = 0; + + // Mark as saved to prevent unnecessary unsaved change warnings + legacyScriptData.saved = true; + + // If we have existing scripts, add the legacy script to them + if (localScripts && localScripts.length > 0) { + // Increment order of existing scripts to make room for legacy script at position 0 + localScripts.forEach(script => { + if (script.order !== undefined && script.order !== null) { + script.order += 1; + } + }); + localScripts = [...localScripts, ...legacyScript]; + } else { + // If no existing scripts, use the legacy script + localScripts = legacyScript; + } + + // Auto-save the legacy script to storage + this._saveScriptsToLocal(appId, localScripts); + } + } + + 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(); + } + + /** + * Generates a unique ID for a new script/tab + * @returns {string} A UUID string + */ + generateScriptId() { + return this._generateScriptId(); + } + + /** + * 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: scriptId, // Keep as string (UUID) instead of parsing as integer + ...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 saved since we're auto-importing it + const script = { + id: this._generateScriptId(), + name: 'Legacy Script', + code: legacyCode, + saved: true, // Mark as saved since this is a one-time migration + 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 using UUID + * @private + */ + _generateScriptId() { + return crypto.randomUUID(); + } +} + +// 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`; +} diff --git a/src/lib/ServerConfigStorage.js b/src/lib/ServerConfigStorage.js new file mode 100644 index 0000000000..edc5f5f367 --- /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 0000000000..1eebf60c26 --- /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 0000000000..27558851a9 --- /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`; +}