diff --git a/package-lock.json b/package-lock.json index bea919f4b..86eb06965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -491,22 +491,22 @@ }, "dependencies": { "browserslist": { - "version": "4.16.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.7.tgz", - "integrity": "sha512-7I4qVwqZltJ7j37wObBe3SoTz+nS8APaNcrBOlgoirb6/HbEU2XxW/LpUDTCngM6iauwFqmRTuOMfyKnFGY5JA==", + "version": "4.16.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.8.tgz", + "integrity": "sha512-sc2m9ohR/49sWEbPj14ZSSZqp+kbi16aLao42Hmn3Z8FpjuMaq2xCA2l4zl9ITfyzvnvyE0hcg62YkIGKxgaNQ==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001248", - "colorette": "^1.2.2", - "electron-to-chromium": "^1.3.793", + "caniuse-lite": "^1.0.30001251", + "colorette": "^1.3.0", + "electron-to-chromium": "^1.3.811", "escalade": "^3.1.1", - "node-releases": "^1.1.73" + "node-releases": "^1.1.75" } }, "electron-to-chromium": { - "version": "1.3.808", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.808.tgz", - "integrity": "sha512-espnsbWTuUw0a2jMwfabCc09py2ujB+FZZE1hZWn5yYijEmxzEhdhTLKUfZGjynHvdIMQ4X/Pr/t8s4eiyH/QQ==", + "version": "1.3.822", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.822.tgz", + "integrity": "sha512-k7jG5oYYHxF4jx6PcqwHX3JVME/OjzolqOZiIogi9xtsfsmTjTdie4x88OakYFPEa8euciTgCCzvVNwvmjHb1Q==", "dev": true }, "semver": { @@ -1986,9 +1986,9 @@ "dev": true }, "@nodelib/fs.walk": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.7.tgz", - "integrity": "sha512-BTIhocbPBSrRmHxOAJFtR18oLhxTtAFDAvL8hY1S3iU8k+E60W/YFs4jrixGzQjMpF4qPXxIQHcjVD9dz1C2QA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "requires": { "@nodelib/fs.scandir": "2.1.5", @@ -2153,9 +2153,9 @@ "dev": true }, "@types/yauzl": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz", - "integrity": "sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz", + "integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==", "dev": true, "optional": true, "requires": { @@ -2456,9 +2456,9 @@ }, "dependencies": { "debug": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", - "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", "dev": true, "requires": { "ms": "2.1.2" @@ -4844,9 +4844,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001248", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001248.tgz", - "integrity": "sha512-NwlQbJkxUFJ8nMErnGtT0QTM2TJ33xgz4KXJSMIrjXIbDVdaYueGyjOrLKRtJC+rTiWfi6j5cnZN1NBiSBJGNw==", + "version": "1.0.30001251", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001251.tgz", + "integrity": "sha512-HOe1r+9VkU4TFmnU70z+r7OLmtR+/chB1rdcJUeQlAinjEeb0cKL20tlAtOagNZhbrtLnCvV19B4FmF1rgzl6A==", "dev": true }, "canvas-fit": { @@ -4990,9 +4990,9 @@ } }, "chromedriver": { - "version": "91.0.1", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-91.0.1.tgz", - "integrity": "sha512-9LktpHiUxM4UWUsr+jI1K1YKx2GENt6BKKJ2mibPj1Wc6ODzX/3fFIlr8CZ4Ftuyga+dHTTbAyPWKwKvybEbKA==", + "version": "92.0.1", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-92.0.1.tgz", + "integrity": "sha512-LptlDVCs1GgyFNVbRoHzzy948JDVzTgGiVPXjNj385qXKQP3hjAVBIgyvb/Hco0xSEW8fjwJfsm1eQRmu6t4pQ==", "dev": true, "requires": { "@testim/chrome-version": "^1.0.7", @@ -5319,9 +5319,9 @@ "dev": true }, "colorette": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", - "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.3.0.tgz", + "integrity": "sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==", "dev": true }, "colormap": { @@ -6626,9 +6626,9 @@ } }, "graceful-fs": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", - "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==", + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", + "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==", "dev": true }, "ignore": { @@ -6884,9 +6884,9 @@ } }, "dompurify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.1.1.tgz", - "integrity": "sha512-NijiNVkS/OL8mdQL1hUbCD6uty/cgFpmNiuFxrmJ5YPH2cXrPKIewoixoji56rbZ6XBPmtM8GA8/sf9unlSuwg==", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.2.7.tgz", + "integrity": "sha512-jdtDffdGNY+C76jvodNTu9jt5yYj59vuTUyx+wXdzcSwAGTYZDAQkQ7Iwx9zcGrA4ixC1syU4H3RZROqRxokxg==", "dev": true }, "domutils": { @@ -8025,9 +8025,9 @@ }, "dependencies": { "debug": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", - "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", "dev": true, "requires": { "ms": "2.1.2" @@ -8064,17 +8064,16 @@ "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" }, "fast-glob": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", - "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.7.tgz", + "integrity": "sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==", "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.0", + "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.2", - "picomatch": "^2.2.1" + "micromatch": "^4.0.4" }, "dependencies": { "braces": { @@ -8133,16 +8132,14 @@ "requires": { "braces": "^3.0.1", "picomatch": "^2.2.3" - }, - "dependencies": { - "picomatch": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", - "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", - "dev": true - } } }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8258,9 +8255,9 @@ "dev": true }, "fastq": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz", - "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.12.0.tgz", + "integrity": "sha512-VNX0QkHK3RsXVKr9KrlUv/FoTa0NdbYoHHl7uXHv2rzyHSlxjdNAKug2twd9luJxpcyNeAgf5iPPMutJO67Dfg==", "dev": true, "requires": { "reusify": "^1.0.4" @@ -8868,9 +8865,9 @@ } }, "follow-redirects": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", - "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==", + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.2.tgz", + "integrity": "sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==", "dev": true }, "font-atlas": { @@ -11077,9 +11074,9 @@ }, "dependencies": { "debug": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", - "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", "dev": true, "requires": { "ms": "2.1.2" @@ -15895,9 +15892,9 @@ } }, "node-releases": { - "version": "1.1.73", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz", - "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==", + "version": "1.1.75", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.75.tgz", + "integrity": "sha512-Qe5OUajvqrqDSy6wrWFmMwfJ0jVgwiw4T3KqmbTcZ62qW0gQkheXYhcFM1+lOVcGUoRxcEcfyvFMAnDgaF1VWw==", "dev": true }, "node-sass": { @@ -20977,21 +20974,35 @@ } }, "scratch-blocks": { - "version": "0.1.0-prerelease.20210818074602", - "resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.20210818074602.tgz", - "integrity": "sha512-6HCt71AvQmmbZgaWB3AGTBRPI02mEbtM5baLwRLB1U1AxCgm3R12ybFqCmxCkLbT7YuPiImSo9IfxZ9X/Wswlw==", + "version": "0.1.0-prerelease.20210829081240", + "resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.20210829081240.tgz", + "integrity": "sha512-pmy397D+sQ4BZzUT3VniKZRYSOgAhAFmqwzbxrobK0oAJYh3BXoVLawZlsnsRQHuEOOPxIWibvuMMQ4EHiYU3Q==", "dev": true, "requires": { "exports-loader": "0.6.3", "google-closure-library": "20190301.0.0", "imports-loader": "0.6.5", - "scratch-l10n": "3.14.20210818031501" + "scratch-l10n": "3.14.20210829031508" + }, + "dependencies": { + "scratch-l10n": { + "version": "3.14.20210829031508", + "resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.14.20210829031508.tgz", + "integrity": "sha512-IRCu17tpoBXM3qYstVPmvtx1DCCcdlXjwQ9JOablEoBCFj4l4IvM1FRdgbmkTaygK5cBChpx+3zvqFbKHVEC4w==", + "dev": true, + "requires": { + "@babel/cli": "^7.1.2", + "@babel/core": "^7.1.2", + "babel-plugin-react-intl": "^3.0.1", + "transifex": "1.6.6" + } + } } }, "scratch-gui": { - "version": "0.1.0-prerelease.20210818113752", - "resolved": "https://registry.npmjs.org/scratch-gui/-/scratch-gui-0.1.0-prerelease.20210818113752.tgz", - "integrity": "sha512-sV0WuEfD5fujDI23I4We5DkKMiAWIgu1Tr/Si4GMj2xpqF3q4U3LlRS4iuHU+nmiVqrPwEr+SXCh4pX8MjwgYw==", + "version": "0.1.0-prerelease.20210901110310", + "resolved": "https://registry.npmjs.org/scratch-gui/-/scratch-gui-0.1.0-prerelease.20210901110310.tgz", + "integrity": "sha512-KjOEftCRigCAU6Qf4jwIbFoK/mBMnnQFsUwBZGB6ojzrIseTwD5qTHpeWMPbgxNKNVO6EHHX9PAL3vT5BVyrhA==", "dev": true, "requires": { "arraybuffer-loader": "^1.0.6", @@ -21042,14 +21053,14 @@ "redux": "3.7.2", "redux-throttle": "0.1.1", "scratch-audio": "0.1.0-prerelease.20200528195344", - "scratch-blocks": "0.1.0-prerelease.20210818074602", - "scratch-l10n": "3.14.20210818031501", - "scratch-paint": "0.2.0-prerelease.20210818072858", - "scratch-render": "0.1.0-prerelease.20210325231800", + "scratch-blocks": "0.1.0-prerelease.20210829081240", + "scratch-l10n": "3.14.20210901031514", + "scratch-paint": "0.2.0-prerelease.20210901075542", + "scratch-render": "0.1.0-prerelease.20210819221425", "scratch-render-fonts": "1.0.0-prerelease.20210401210003", "scratch-storage": "1.3.5", "scratch-svg-renderer": "0.2.0-prerelease.20210727023023", - "scratch-vm": "0.2.0-prerelease.20210812043817", + "scratch-vm": "0.2.0-prerelease.20210901070526", "startaudiocontext": "1.2.1", "style-loader": "^0.23.0", "text-encoding": "0.7.0", @@ -21089,16 +21100,16 @@ "dev": true }, "browserslist": { - "version": "4.16.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.7.tgz", - "integrity": "sha512-7I4qVwqZltJ7j37wObBe3SoTz+nS8APaNcrBOlgoirb6/HbEU2XxW/LpUDTCngM6iauwFqmRTuOMfyKnFGY5JA==", + "version": "4.16.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.8.tgz", + "integrity": "sha512-sc2m9ohR/49sWEbPj14ZSSZqp+kbi16aLao42Hmn3Z8FpjuMaq2xCA2l4zl9ITfyzvnvyE0hcg62YkIGKxgaNQ==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001248", - "colorette": "^1.2.2", - "electron-to-chromium": "^1.3.793", + "caniuse-lite": "^1.0.30001251", + "colorette": "^1.3.0", + "electron-to-chromium": "^1.3.811", "escalade": "^3.1.1", - "node-releases": "^1.1.73" + "node-releases": "^1.1.75" } }, "chalk": { @@ -21212,9 +21223,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.3.811", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.811.tgz", - "integrity": "sha512-hv3kgf6YSd+jQ7J+7Kdm44yux/1vxcAwfGV/6M6Nq4E9zJ3Bml/P2+vULCvqLS6Lh9knBCQ7iEMvyeDiGe5EbA==", + "version": "1.3.826", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.826.tgz", + "integrity": "sha512-bpLc4QU4B8PYmdO4MSu2ZBTMD8lAaEXRS43C09lB31BvYwuk9UxgBRXbY5OJBw7VuMGcg2MZG5FyTaP9u4PQnw==", "dev": true }, "has-flag": { @@ -21478,9 +21489,9 @@ } }, "scratch-l10n": { - "version": "3.14.20210818031501", - "resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.14.20210818031501.tgz", - "integrity": "sha512-UlOFn/DhrKAMfbvSUNmdBhDwr6z8zhwPMwBoiIHajllraVELfUsgAyFZgJyCriwpt6Ojxe+aGCn+lTuqyYpIow==", + "version": "3.14.20210901031514", + "resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.14.20210901031514.tgz", + "integrity": "sha512-sIjW6dTUypcRuLg1BY5LCZW2y97jmE+qa8RGUmJzxrqqi4hqvxqGYui7BevDB0C2+pfT5gxqS33zx+/JVqDkew==", "dev": true, "requires": { "@babel/cli": "^7.1.2", @@ -21490,9 +21501,9 @@ } }, "scratch-paint": { - "version": "0.2.0-prerelease.20210818072858", - "resolved": "https://registry.npmjs.org/scratch-paint/-/scratch-paint-0.2.0-prerelease.20210818072858.tgz", - "integrity": "sha512-jsQ0Swpms86ky5uP/eETr27uhV7zc5AphBvTSg+7lt3ZC2oxToRuaOP7EQSUf/7seHd1ph2AQwf+gN6NiofxFw==", + "version": "0.2.0-prerelease.20210901075542", + "resolved": "https://registry.npmjs.org/scratch-paint/-/scratch-paint-0.2.0-prerelease.20210901075542.tgz", + "integrity": "sha512-G5RyWAc149JRcx/5Mr1w0t2xnQzB2kwmj2iE5hIv8U76qboTwYkKfHMwETCQnm6O3/kuIy8W1I60P6jldO46kg==", "dev": true, "requires": { "@scratch/paper": "0.11.20200728195508", @@ -21551,9 +21562,9 @@ } }, "scratch-render": { - "version": "0.1.0-prerelease.20210325231800", - "resolved": "https://registry.npmjs.org/scratch-render/-/scratch-render-0.1.0-prerelease.20210325231800.tgz", - "integrity": "sha512-hjiIHRR8SuP/8UKKZ4O+TIJaCZ2wSN6uoEM49jwNjZecAaflBvd5t/OLL3NFQp3q7Ra6ncDi+B7URy7WRdm2fg==", + "version": "0.1.0-prerelease.20210819221425", + "resolved": "https://registry.npmjs.org/scratch-render/-/scratch-render-0.1.0-prerelease.20210819221425.tgz", + "integrity": "sha512-2kB/NKEEqiXmHUDynA2O5tQJFF3o2s3JmcNecjgOCsR+FuthH2oIsHFwKbyLn93hXJICDRRW+oc3ikqj7G5O+w==", "dev": true, "requires": { "grapheme-breaker": "0.3.2", @@ -21563,7 +21574,7 @@ "minilog": "3.1.0", "raw-loader": "^0.5.1", "scratch-storage": "^1.0.0", - "scratch-svg-renderer": "0.2.0-prerelease.20210317184701", + "scratch-svg-renderer": "0.2.0-prerelease.20210727023023", "twgl.js": "4.4.0" }, "dependencies": { @@ -21594,15 +21605,6 @@ "microee": "0.0.6" } }, - "scratch-render-fonts": { - "version": "1.0.0-prerelease.20200507182347", - "resolved": "https://registry.npmjs.org/scratch-render-fonts/-/scratch-render-fonts-1.0.0-prerelease.20200507182347.tgz", - "integrity": "sha512-tVt2s7lxsBhme9WKIZTnluMerdJVGEc80QSrDdIIzUvHXGCIYkLh6j7ytwXcyq2UsA34d93op9+I9Bh1SPkQkA==", - "dev": true, - "requires": { - "base64-loader": "1.0.0" - } - }, "scratch-storage": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-1.3.5.tgz", @@ -21616,28 +21618,6 @@ "minilog": "3.1.0", "worker-loader": "^2.0.0" } - }, - "scratch-svg-renderer": { - "version": "0.2.0-prerelease.20210317184701", - "resolved": "https://registry.npmjs.org/scratch-svg-renderer/-/scratch-svg-renderer-0.2.0-prerelease.20210317184701.tgz", - "integrity": "sha512-drHD8kRTU//Rqgs8F6oWmBIQi6TunI86Skvp7BfM+mqalds3GzaPjZHKSCFkdkXbHO4i/zAPLvkQtMDdLm4Y6g==", - "dev": true, - "requires": { - "base64-js": "1.2.1", - "base64-loader": "1.0.0", - "dompurify": "2.1.1", - "minilog": "3.1.0", - "scratch-render-fonts": "1.0.0-prerelease.20200507182347", - "transformation-matrix": "1.15.0" - }, - "dependencies": { - "base64-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.1.tgz", - "integrity": "sha512-dwVUVIXsBZXwTuwnXI9RK8sBmgq09NDHzyR9SAph9eqk76gKK2JSQmZARC2zRC81JC2QTtxD0ARU5qTS25gIGw==", - "dev": true - } - } } } }, @@ -21716,12 +21696,6 @@ "transformation-matrix": "1.15.0" }, "dependencies": { - "dompurify": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.2.7.tgz", - "integrity": "sha512-jdtDffdGNY+C76jvodNTu9jt5yYj59vuTUyx+wXdzcSwAGTYZDAQkQ7Iwx9zcGrA4ixC1syU4H3RZROqRxokxg==", - "dev": true - }, "microee": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/microee/-/microee-0.0.6.tgz", @@ -21746,9 +21720,9 @@ "dev": true }, "scratch-vm": { - "version": "0.2.0-prerelease.20210812043817", - "resolved": "https://registry.npmjs.org/scratch-vm/-/scratch-vm-0.2.0-prerelease.20210812043817.tgz", - "integrity": "sha512-DFWe5YGASTJ7bNIy4wMXjaAItNlJvxpH5qbjDZxosuU1XVdmipaUDJt4F4G+IfSqb+EwExtX4XS01FiWThqQ6w==", + "version": "0.2.0-prerelease.20210901070526", + "resolved": "https://registry.npmjs.org/scratch-vm/-/scratch-vm-0.2.0-prerelease.20210901070526.tgz", + "integrity": "sha512-Ibc/p80DedCW96fQ4P1s5aekKNRw/TAUD7px6NEgzPnzOC1XbPJic0Ijpg9vnnfxx9sM+XGXtxN2a+8z+FN4+g==", "dev": true, "requires": { "@vernier/godirect": "1.5.0", diff --git a/package.json b/package.json index 5623bb765..7ab5a0f91 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "babel-preset-es2015": "6.22.0", "babel-preset-react": "6.22.0", "bowser": "1.9.4", - "chromedriver": "91.0.1", + "chromedriver": "92.0.1", "classnames": "2.2.5", "cookie": "0.4.1", "copy-webpack-plugin": "4.6.0", @@ -130,8 +130,8 @@ "redux-mock-store": "1.5.4", "redux-thunk": "2.0.1", "sass-loader": "6.0.6", - "scratch-gui": "0.1.0-prerelease.20210818113752", - "scratch-l10n": "3.14.20210818031501", + "scratch-gui": "0.1.0-prerelease.20210901110310", + "scratch-l10n": "3.14.20210901031514", "selenium-webdriver": "3.6.0", "slick-carousel": "1.6.0", "style-loader": "0.12.3", diff --git a/src/components/alert/alert-provider.jsx b/src/components/alert/alert-provider.jsx index 2a75acd80..f6649f049 100644 --- a/src/components/alert/alert-provider.jsx +++ b/src/components/alert/alert-provider.jsx @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import AlertStatus from './alert-status.js'; import AlertContext from './alert-context.js'; +const DEFAULT_TIMEOUT_SECONDS = 6; + const AlertProvider = ({children}) => { const defaultState = { status: AlertStatus.NONE, @@ -19,7 +21,7 @@ const AlertProvider = ({children}) => { setState(defaultState); }; - const handleAlert = (status, data, timeoutSeconds = 3) => { + const handleAlert = (status, data, timeoutSeconds = DEFAULT_TIMEOUT_SECONDS) => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setState({status, data, showClear: !timeoutSeconds}); if (timeoutSeconds) { @@ -37,9 +39,9 @@ const AlertProvider = ({children}) => { data: state.data, showClear: state.showClear, clearAlert: clearAlert, - successAlert: (newData, timeoutSeconds = 3) => + successAlert: (newData, timeoutSeconds = DEFAULT_TIMEOUT_SECONDS) => handleAlert(AlertStatus.SUCCESS, newData, timeoutSeconds), - errorAlert: (newData, timeoutSeconds = 3) => + errorAlert: (newData, timeoutSeconds = DEFAULT_TIMEOUT_SECONDS) => handleAlert(AlertStatus.ERROR, newData, timeoutSeconds) }} > diff --git a/src/redux/session.js b/src/redux/session.js index f7d762017..ca775f29d 100644 --- a/src/redux/session.js +++ b/src/redux/session.js @@ -149,6 +149,8 @@ module.exports.selectMuteStatus = state => get(state, ['session', 'session', 'pe module.exports.selectIsMuted = state => (module.exports.selectMuteStatus(state).muteExpiresAt || 0) * 1000 > Date.now(); module.exports.selectNewStudiosLaunched = state => get(state, ['session', 'session', 'flags', 'new_studios_launched'], false); +module.exports.selectStudioTransferLaunched = state => get(state, ['session', 'session', 'flags', + 'studio_transfer_launched'], false); module.exports.selectHasFetchedSession = state => state.session.status === module.exports.Status.FETCHED; diff --git a/src/redux/studio-permissions.js b/src/redux/studio-permissions.js index 6a00ffa08..99b75f9d9 100644 --- a/src/redux/studio-permissions.js +++ b/src/redux/studio-permissions.js @@ -3,12 +3,12 @@ const {selectUserId, selectIsAdmin, selectIsSocial, selectHasFetchedSession, selectStudioCommentsGloballyEnabled} = require('./session'); // Fine-grain selector helpers - not exported, use the higher level selectors below -const isCreator = state => selectUserId(state) === state.studio.owner; +const isHost = state => selectUserId(state) === state.studio.owner; const isCurator = state => state.studio.curator; -const isManager = state => state.studio.manager || isCreator(state); +const isManager = state => state.studio.manager || isHost(state); // Action-based permissions selectors -const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state)); +const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isHost(state)); const selectCanAddProjects = state => !selectIsMuted(state) && (isManager(state) || @@ -35,7 +35,7 @@ const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state); const selectCanFollowStudio = state => selectIsLoggedIn(state); // Matching existing behavior, only admin/creator is allowed to toggle comments. -const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state)); +const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isHost(state)); const selectCanEditOpenToAll = state => !selectIsMuted(state) && isManager(state); const selectShowCuratorInvite = state => !selectIsMuted(state) && !!state.studio.invited; @@ -54,6 +54,20 @@ const selectCanRemoveManager = (state, managerId) => !selectIsMuted(state) && (selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner; const selectCanPromoteCurators = state => !selectIsMuted(state) && isManager(state); +const selectCanTransfer = (state, managerId) => { + // Nobody can transfer a class studio. + // classroomId is loaded only for educator and admin users. Only educators can create class studios, + // so educators and admins are the only users who otherwise would be able to transfer a class studio. + if (state.studio.classroomId !== null) return false; + if (state.studio.managers > 1) { // If there is more than one manager, + if (managerId === state.studio.owner) { // and the selected manager is the current owner/host, + if (isHost(state)) return true; // Owner/host can transfer + if (selectIsAdmin(state)) return true; // Admin can transfer + } + } + return false; +}; + const selectCanRemoveProject = (state, creatorUsername, actorId) => { if (selectIsMuted(state)) return false; @@ -73,7 +87,7 @@ const selectCanRemoveProject = (state, creatorUsername, actorId) => { // We should only show the mute errors to muted users who have any permissions related to the content // TODO these duplicate the behavior embedded in the non-muted parts of the selectors above, it would be good // to extract this. -const selectShowEditMuteError = state => selectIsMuted(state) && (isCreator(state) || selectIsAdmin(state)); +const selectShowEditMuteError = state => selectIsMuted(state) && (isHost(state) || selectIsAdmin(state)); const selectShowProjectMuteError = state => selectIsMuted(state) && (selectIsAdmin(state) || isManager(state) || @@ -99,6 +113,7 @@ export { selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators, + selectCanTransfer, selectCanRemoveProject, selectShowCommentsList, selectShowCommentsGloballyOffError, diff --git a/src/redux/studio.js b/src/redux/studio.js index ecbd58ad1..0fa77c7ba 100644 --- a/src/redux/studio.js +++ b/src/redux/studio.js @@ -4,7 +4,7 @@ const {withAdmin} = require('../lib/admin-requests'); const api = require('../lib/api'); const log = require('../lib/log'); -const {selectUsername, selectToken, selectIsEducator} = require('./session'); +const {selectUsername, selectToken, selectIsEducator, selectIsAdmin} = require('./session'); const Status = keyMirror({ FETCHED: null, @@ -28,7 +28,7 @@ const getInitialState = () => ({ owner: null, public: null, - // BEWARE: classroomId is only loaded if the user is an educator + // BEWARE: classroomId is only loaded if the user is an educator or admin classroomId: null, rolesStatus: Status.NOT_FETCHED, @@ -164,7 +164,7 @@ const getRoles = () => ((dispatch, getState) => { }); // Since the user is now loaded, it's a good time to check if the studio is part of a classroom - if (selectIsEducator(state)) { + if (selectIsEducator(state) || selectIsAdmin(state)) { api({uri: `/studios/${studioId}/classroom`}, (err, body, res) => { // No error states for inability/problems loading classroom, just swallow them if (!err && res.statusCode === 200 && body) dispatch(setInfo({classroomId: body.id})); diff --git a/src/views/messages/l10n.json b/src/views/messages/l10n.json index 6de138777..2e5374884 100644 --- a/src/views/messages/l10n.json +++ b/src/views/messages/l10n.json @@ -5,6 +5,8 @@ "messages.activityStudios": "Studio Activity", "messages.activityForums": "Forum Activity", "messages.becomeManagerText": "{username} promoted you to manager for the studio {studio}", + "messages.becomeHostText": "{usernameOrScratchTeam} made you the host of the studio {studio}. As host, you now have the ability to edit the studio title, thumbnail, and description. Go say hello in the studio!", + "messages.becomeHostScratchTeam": "A Scratch Team member", "messages.curatorInviteText": "{actorLink} invited you to curate the studio {studioLink}. Visit the {tabLink} on the studio to accept the invitation", "messages.curatorTabText": "curator tab", "messages.favoriteText": "{profileLink} favorited your project {projectLink}", diff --git a/src/views/messages/message-rows/become-host.jsx b/src/views/messages/message-rows/become-host.jsx new file mode 100644 index 000000000..5eed3fd1c --- /dev/null +++ b/src/views/messages/message-rows/become-host.jsx @@ -0,0 +1,50 @@ +const classNames = require('classnames'); +const FormattedMessage = require('react-intl').FormattedMessage; +const PropTypes = require('prop-types'); +const React = require('react'); + +const SocialMessage = require('../../../components/social-message/social-message.jsx'); + +const BecomeHostMessage = props => ( + + : + + {props.actorUsername} + + ), + studio: ( + + {props.studioTitle} + + ) + }} + /> + +); + +BecomeHostMessage.propTypes = { + actorUsername: PropTypes.string.isRequired, + adminActor: PropTypes.bool, + className: PropTypes.string, + datetimePromoted: PropTypes.string.isRequired, + studioId: PropTypes.number.isRequired, + studioTitle: PropTypes.string.isRequired +}; + +module.exports = BecomeHostMessage; diff --git a/src/views/messages/presentation.jsx b/src/views/messages/presentation.jsx index eb8931c4a..e8036b392 100644 --- a/src/views/messages/presentation.jsx +++ b/src/views/messages/presentation.jsx @@ -17,6 +17,7 @@ const messageStatuses = require('../../redux/messages').Status; // Message Components const AdminMessage = require('./message-rows/admin-message.jsx'); const BecomeManagerMessage = require('./message-rows/become-manager.jsx'); +const BecomeHostMessage = require('./message-rows/become-host.jsx'); const CommentMessage = require('./message-rows/comment-message.jsx'); const CuratorInviteMessage = require('./message-rows/curator-invite.jsx'); const FavoriteProjectMessage = require('./message-rows/favorite-project.jsx'); @@ -147,6 +148,18 @@ class SocialMessagesList extends React.Component { studioTitle={message.gallery_title} /> ); + case 'becomehoststudio': + return ( + + ); case 'userjoin': return ( { if (res.statusCode === 403 && body.mute_status) return Errors.USER_MUTED; if (res.statusCode === 401 || res.statusCode === 403) return Errors.PERMISSION; if (res.statusCode === 404) return Errors.UNKNOWN_USERNAME; + if (res.statusCode === 409) return Errors.CANNOT_BE_HOST; if (res.statusCode === 429) return Errors.RATE_LIMIT; if (res.statusCode !== 200) return Errors.SERVER; if (body && body.status === 'error') { @@ -187,6 +189,27 @@ const acceptInvitation = () => ((dispatch, getState) => new Promise((resolve, re }); })); +const transferHost = (password, newHostName, newHostId) => + ((dispatch, getState) => new Promise((resolve, reject) => { + const state = getState(); + const studioId = selectStudioId(state); + const token = selectToken(state); + newHostName = newHostName.trim(); + api({ + uri: `/studios/${studioId}/transfer/${newHostName}`, + method: 'PUT', + authentication: token, + withCredentials: true, + useCsrf: true, + json: {password: password} + }, (err, body, res) => { + const error = normalizeError(err, body, res); + if (error) return reject(error); + dispatch(setInfo({owner: newHostId})); + return resolve(); + }); + })); + export { Errors, loadManagers, @@ -195,5 +218,6 @@ export { acceptInvitation, promoteCurator, removeCurator, - removeManager + removeManager, + transferHost }; diff --git a/src/views/studio/modals/studio-report-modal.scss b/src/views/studio/modals/studio-report-modal.scss index 2449ff143..b68d8f2a6 100644 --- a/src/views/studio/modals/studio-report-modal.scss +++ b/src/views/studio/modals/studio-report-modal.scss @@ -61,6 +61,7 @@ overflow-wrap: break-word; height: 90px; padding: 0.5rem; + white-space: pre-wrap; } .studio-report-title-text { diff --git a/src/views/studio/modals/transfer-host-confirmation.jsx b/src/views/studio/modals/transfer-host-confirmation.jsx new file mode 100644 index 000000000..6c4a951f4 --- /dev/null +++ b/src/views/studio/modals/transfer-host-confirmation.jsx @@ -0,0 +1,189 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import {FormattedMessage} from 'react-intl'; +const {injectIntl, intlShape} = require('react-intl'); + +import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx'; + +import TransferHostTile from './transfer-host-tile.jsx'; +import Form from '../../../components/forms/form.jsx'; +import ValidationMessage from '../../../components/forms/validation-message.jsx'; + +import {managers} from '../lib/redux-modules'; + +import {useAlertContext} from '../../../components/alert/alert-context'; +import {Errors, transferHost} from '../lib/studio-member-actions'; + +import './transfer-host-modal.scss'; + +const TransferHostConfirmation = ({ + handleBack, + handleClose, + handleTransferHost, + intl, + items, + hostId, + selectedId +}) => { + const currentHostUsername = items.find(item => item.id === hostId).username; + const currentHostImage = items.find(item => item.id === hostId).profile.images['90x90']; + const newHostUsername = items.find(item => item.id === selectedId).username; + const newHostImage = items.find(item => item.id === selectedId).profile.images['90x90']; + const [passwordInputValue, setPasswordInputValue] = useState(''); + const [validationError, setValidationError] = useState(null); + const {errorAlert, successAlert} = useAlertContext(); + + const errorToMessageId = error => { + switch (error) { + case Errors.RATE_LIMIT: return 'studio.alertTransferRateLimit'; + case Errors.CANNOT_BE_HOST: return 'studio.transfer.alert.thisUserCannotBecomeHost'; + default: return 'studio.transfer.alert.somethingWentWrong'; + } + }; + + const handleSubmit = () => { + handleTransferHost(passwordInputValue, newHostUsername, selectedId) + .then(() => { + handleClose(); + successAlert({ + id: 'studio.alertTransfer', + values: {name: newHostUsername} + }); + }) + .catch(e => { + // For password errors, show validation alert without closing the modal + if (e === Errors.PERMISSION) { + setValidationError(e); + return; + } + // For other errors, close the modal and show an alert + handleClose(); + errorAlert({ + id: errorToMessageId(e) + }); + }); + }; + + const handleChangePasswordInput = e => { + setPasswordInputValue(e.target.value); + setValidationError(null); + }; + + return ( + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+

+ +

+
+
+
+ + {validationError && } +
+
+ + + +
+
+ + +
+
+
+ ); +}; + +TransferHostConfirmation.propTypes = { + handleBack: PropTypes.func, + handleClose: PropTypes.func, + intl: intlShape, + items: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.id, + username: PropTypes.string, + profile: PropTypes.shape({ + images: PropTypes.shape({ + '90x90': PropTypes.string + }) + }) + })), + handleTransferHost: PropTypes.func, + selectedId: PropTypes.number, + hostId: PropTypes.number +}; + +const connectedConfirmationStep = connect( + state => ({ + hostId: state.studio.owner, + ...managers.selector(state) + }), { + handleTransferHost: transferHost + } +)(TransferHostConfirmation); + +export default injectIntl(connectedConfirmationStep); diff --git a/src/views/studio/modals/transfer-host-info.jsx b/src/views/studio/modals/transfer-host-info.jsx new file mode 100644 index 000000000..6adceaaa1 --- /dev/null +++ b/src/views/studio/modals/transfer-host-info.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; + +import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx'; + +import AlertComponent from '../../../components/alert/alert-component.jsx'; +import errorIcon from '../../../components/alert/icon-alert-error.svg'; + +import './transfer-host-modal.scss'; + +const TransferHostInfo = ({ + handleClose, + handleNext +}) => + (
+ + +
+

+ +

+
+
+ +
+ + + +
    +
  • +
  • +
+
+ + +
+
+
); + +TransferHostInfo.propTypes = { + handleClose: PropTypes.func, + handleNext: PropTypes.func +}; + +export default TransferHostInfo; diff --git a/src/views/studio/modals/transfer-host-modal.jsx b/src/views/studio/modals/transfer-host-modal.jsx new file mode 100644 index 000000000..da16c88d3 --- /dev/null +++ b/src/views/studio/modals/transfer-host-modal.jsx @@ -0,0 +1,58 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; +import keyMirror from 'keymirror'; + +import Modal from '../../../components/modal/base/modal.jsx'; +import ModalTitle from '../../../components/modal/base/modal-title.jsx'; + +import TransferHostInfo from './transfer-host-info.jsx'; +import TransferHostSelection from './transfer-host-selection.jsx'; +import TransferHostConfirmation from './transfer-host-confirmation.jsx'; + +import './transfer-host-modal.scss'; + +const STEPS = keyMirror({ + info: null, + selection: null, + confirmation: null +}); + +const TransferHostModal = ({ + handleClose +}) => { + const [step, setStep] = useState(STEPS.info); + const [selectedId, setSelectedId] = useState(null); + return ( + } + /> + {step === STEPS.info && setStep(STEPS.selection)} // eslint-disable-line react/jsx-no-bind + />} + {step === STEPS.selection && setStep(STEPS.confirmation)} // eslint-disable-line react/jsx-no-bind + handleBack={() => setStep(STEPS.info)} // eslint-disable-line react/jsx-no-bind + handleSelected={setSelectedId} + selectedId={selectedId} + />} + {step === STEPS.confirmation && setStep(STEPS.selection)} // eslint-disable-line react/jsx-no-bind + selectedId={selectedId} + />} + ); +}; + +TransferHostModal.propTypes = { + handleClose: PropTypes.func +}; + +export default TransferHostModal; diff --git a/src/views/studio/modals/transfer-host-modal.scss b/src/views/studio/modals/transfer-host-modal.scss new file mode 100644 index 000000000..71c2bfbbd --- /dev/null +++ b/src/views/studio/modals/transfer-host-modal.scss @@ -0,0 +1,186 @@ +@import "../../../colors"; +@import "../../../frameless"; + +.transfer-host-modal { + .transfer-host-title { + background: $ui-blue; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + padding-top: .75rem; + width: 100%; + height: 3rem; + cursor: pointer; + } + + .transfer-info-title { + margin-top: 3rem; + } + + h2 { + line-height: 2.5rem; + margin-bottom: 1rem; + } + + .list-header { + font-weight: bold; + } + + ul { + line-height: 1rem; + margin-top: 0px; + } + + .content { + display: flex; + align-items: flex-start; + } + + .transfer-host-image { + margin-top: 2rem; + } + + .inner { + padding: 1rem; + } + + .transfer-host-alert-wrapper { + margin-right: 2rem; + } + + .transfer-host-alert-wrapper .alert-wrapper { + position: relative; + display: block; + margin-bottom: 2rem; + } + + .transfer-host-alert .alert-msg { + font-size: 1rem; + } + + .transfer-host-button-row { + display: flex; + justify-content: flex-end; + padding-top: 1.5rem; + } + + .transfer-host-button-row-split { + justify-content: space-between; + } + + .transfer-selection-buttons { + padding: 1rem; + } + + .button { + margin: 0px; + } + + .button:disabled { + background-color: $active-dark-gray; + } + + .cancel-button { + background-color: $ui-white; + color: $ui-blue; + box-shadow: 0px 0px 0 1px $ui-blue; + margin-right: 1rem; + } + + .next-button { + min-width: 5rem; + } + + .transfer-selection-heading { + padding: 1rem; + background: $ui-blue-10percent; + } + + .transfer-selection-scroll-pane { + height: 250px; + padding-left: 1rem; + padding-right: 1rem; + background: $ui-blue-10percent; + overflow: auto; + } + + .transfer-host-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0,1fr)); + @media #{$intermediate-and-smaller} { + & { grid-template-columns: repeat(2, minmax(0,1fr)); } + } + column-gap: 12px; + row-gap: 12px; + margin-bottom: 1rem; + } + + .transfer-host-name-selected { + color: white !important; + } + + .transfer-selection-icon { + margin: auto 8px; + } + + .transfer-host-tile-selected { + background: $ui-aqua; + } + + .transfer-password-instruction { + padding: 3rem 3rem 2rem; + } + + .transfer-form { + padding: 0px 1rem 1rem; + } + + .transfer-password-input { + margin-left: 2rem; + border: 1px solid $ui-border; + border-radius: .5rem; + padding: 0.5rem 1rem; + font-size: 1.5rem; + } + + .transfer-password-row { + display: flex; + align-items: center; + margin-bottom: 1rem; + } + + .transfer-password-validation { + position: relative; + transform: translate(1rem, 0); + } + + .col-sm-9 .input { + font-size: 1.5rem; + width: 50%; + } + + .transfer-forgot-link { + margin: 0px 2rem 3rem; + } + + .transfer-outcome { + background: $ui-blue-10percent; + padding: 2rem 3rem; + display: flex; + } + + .transfer-outcome-tile { + width: 220px; + box-shadow: 0px 3px 5px $box-shadow-light-gray; + } + + .transfer-outcome-label { + margin-bottom: 0.5rem; + font-weight: bold; + font-size: 12px; + } + + .transfer-outcome-arrow { + width: 40px; + margin: auto 3rem 1rem 3rem; + } +} \ No newline at end of file diff --git a/src/views/studio/modals/transfer-host-selection.jsx b/src/views/studio/modals/transfer-host-selection.jsx new file mode 100644 index 000000000..3e20ab9b3 --- /dev/null +++ b/src/views/studio/modals/transfer-host-selection.jsx @@ -0,0 +1,115 @@ +import React, {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import {FormattedMessage} from 'react-intl'; +import classNames from 'classnames'; + +import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx'; + +import TransferHostTile from './transfer-host-tile.jsx'; + +import {managers} from '../lib/redux-modules'; +import {loadManagers} from '../lib/studio-member-actions'; + +import './transfer-host-modal.scss'; + +const TransferHostSelection = ({ + handleSelected, + handleNext, + handleBack, + loading, + moreToLoad, + onLoadMore, + items, + hostId, + selectedId +}) => { + useEffect(() => { + if (items.length === 0) onLoadMore(); + }, []); + + return ( + +
+

+ +

+
+
+
+ {items.filter(item => hostId !== item.id).map(item => + ( handleSelected(item.id)} + id={item.id} + username={item.username} + image={item.profile.images['90x90']} + isCreator={false} + selected={item.id === selectedId} + />) + )} + {moreToLoad && +
+ +
+ } +
+
+
+ + +
+
+ ); +}; + +TransferHostSelection.propTypes = { + handleBack: PropTypes.func, + handleNext: PropTypes.func, + handleSelected: PropTypes.func, + items: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.id, + username: PropTypes.string, + profile: PropTypes.shape({ + images: PropTypes.shape({ + '90x90': PropTypes.string + }) + }) + })), + loading: PropTypes.bool, + moreToLoad: PropTypes.bool, + onLoadMore: PropTypes.func, + selectedId: PropTypes.number, + hostId: PropTypes.number +}; + +export default connect( + state => ({ + hostId: state.studio.owner, + ...managers.selector(state) + }), + { + onLoadMore: loadManagers + } +)(TransferHostSelection); diff --git a/src/views/studio/modals/transfer-host-tile.jsx b/src/views/studio/modals/transfer-host-tile.jsx new file mode 100644 index 000000000..84afe9179 --- /dev/null +++ b/src/views/studio/modals/transfer-host-tile.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +const TransferHostTile = ({ + className, username, image, selected, handleSelected +}) => ( +
+ +
+
+ {username} +
+
+ {selected && +
+ +
} +
+); + +TransferHostTile.propTypes = { + className: PropTypes.string, + username: PropTypes.string, + handleSelected: PropTypes.func, + image: PropTypes.string, + selected: PropTypes.bool +}; + +export default TransferHostTile; diff --git a/src/views/studio/studio-member-tile.jsx b/src/views/studio/studio-member-tile.jsx index ee75655fa..2171b9042 100644 --- a/src/views/studio/studio-member-tile.jsx +++ b/src/views/studio/studio-member-tile.jsx @@ -7,10 +7,13 @@ import {FormattedMessage} from 'react-intl'; import PromoteModal from './modals/promote-modal.jsx'; import ManagerLimitModal from './modals/manager-limit-modal.jsx'; +import TransferHostModal from './modals/transfer-host-modal.jsx'; import { - selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators + selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators, + selectCanTransfer } from '../../redux/studio-permissions'; +import {selectStudioTransferLaunched} from '../../redux/session.js'; import { Errors, promoteCurator, @@ -26,11 +29,13 @@ import removeIcon from './icons/remove-icon.svg'; import promoteIcon from './icons/curator-icon.svg'; const StudioMemberTile = ({ - canRemove, canPromote, onRemove, onPromote, isCreator, hasReachedManagerLimit, // mapState props + canRemove, canPromote, onRemove, canTransferHost, onPromote, + isCreator, hasReachedManagerLimit, // mapState props username, image // own props }) => { const [submitting, setSubmitting] = useState(false); - const [modalOpen, setModalOpen] = useState(false); + const [promoteModalOpen, setPromoteModalOpen] = useState(false); + const [transferHostModalOpen, setTransferHostModalOpen] = useState(false); const [managerLimitReached, setManagerLimitReached] = useState(false); const {errorAlert, successAlert} = useAlertContext(); const userUrl = `/users/${username}`; @@ -49,12 +54,12 @@ const StudioMemberTile = ({ >{username} {isCreator &&
} - {(canRemove || canPromote) && + {(canRemove || canPromote || canTransferHost) && {canPromote &&
  • } + {canTransferHost &&
  • + +
  • }
    } - {modalOpen && + {promoteModalOpen && ((hasReachedManagerLimit || managerLimitReached) ? setModalOpen(false)} + handleClose={() => setPromoteModalOpen(false)} /> : setModalOpen(false)} + handleClose={() => setPromoteModalOpen(false)} handlePromote={() => { onPromote(username) .then(() => { @@ -102,7 +118,7 @@ const StudioMemberTile = ({ .catch(error => { if (error === Errors.MANAGER_LIMIT) { setManagerLimitReached(true); - setModalOpen(true); + setPromoteModalOpen(true); } else { errorAlert({ id: 'studio.alertManagerPromoteError', @@ -115,6 +131,11 @@ const StudioMemberTile = ({ /> ) } + {transferHostModalOpen && + setTransferHostModalOpen(false)} + /> + } ); }; @@ -122,6 +143,7 @@ const StudioMemberTile = ({ StudioMemberTile.propTypes = { canRemove: PropTypes.bool, canPromote: PropTypes.bool, + canTransferHost: PropTypes.bool, onRemove: PropTypes.func, onPromote: PropTypes.func, username: PropTypes.string, @@ -134,6 +156,8 @@ const ManagerTile = connect( (state, ownProps) => ({ canRemove: selectCanRemoveManager(state, ownProps.id), canPromote: false, + canTransferHost: selectCanTransfer(state, ownProps.id) && + selectStudioTransferLaunched(state), isCreator: state.studio.owner === ownProps.id }), { diff --git a/src/views/studio/studio.scss b/src/views/studio/studio.scss index dba475b44..30b18490c 100644 --- a/src/views/studio/studio.scss +++ b/src/views/studio/studio.scss @@ -399,6 +399,11 @@ $radius: 8px; background: transparent; border: none; } + + .studio-member-tile-menu-wide { + white-space: nowrap; + padding-right: 2rem !important; + } } .studio-members + .studio-members { diff --git a/static/svgs/messages/host-transfer.svg b/static/svgs/messages/host-transfer.svg new file mode 100644 index 000000000..bc749360d --- /dev/null +++ b/static/svgs/messages/host-transfer.svg @@ -0,0 +1,12 @@ + + + + studio invite - curate + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/static/svgs/studio/icon-alert-error.svg b/static/svgs/studio/icon-alert-error.svg new file mode 100644 index 000000000..6dc116bdf --- /dev/null +++ b/static/svgs/studio/icon-alert-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/svgs/studio/r-arrow.svg b/static/svgs/studio/r-arrow.svg new file mode 100644 index 000000000..142288cb3 --- /dev/null +++ b/static/svgs/studio/r-arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/svgs/studio/transfer-host.svg b/static/svgs/studio/transfer-host.svg new file mode 100644 index 000000000..5b164d232 --- /dev/null +++ b/static/svgs/studio/transfer-host.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/test/integration/footer-links.test.js b/test/integration/footer-links.test.js index 1e2eadd2f..d6b39af3f 100644 --- a/test/integration/footer-links.test.js +++ b/test/integration/footer-links.test.js @@ -1,3 +1,5 @@ +// these tests do not sign in as a user + const SeleniumHelper = require('./selenium-helpers.js'); const { diff --git a/test/integration/homepage-rows.test.js b/test/integration/homepage-rows.test.js index a49f6b558..84bf8fc52 100644 --- a/test/integration/homepage-rows.test.js +++ b/test/integration/homepage-rows.test.js @@ -1,3 +1,5 @@ +// these tests do not sign in as a user + const SeleniumHelper = require('./selenium-helpers.js'); const { diff --git a/test/integration/join.test.js b/test/integration/join.test.js index c8cd95ea8..a08e6861d 100644 --- a/test/integration/join.test.js +++ b/test/integration/join.test.js @@ -1,3 +1,5 @@ +// these tests do not sign in as a user + const SeleniumHelper = require('./selenium-helpers.js'); const { diff --git a/test/integration/my-stuff.test.js b/test/integration/my-stuff.test.js index 5a409840f..1bc650dd1 100644 --- a/test/integration/my-stuff.test.js +++ b/test/integration/my-stuff.test.js @@ -1,3 +1,5 @@ +// These tests sign in as user #1 + const SeleniumHelper = require('./selenium-helpers.js'); const { diff --git a/test/integration/navbar.test.js b/test/integration/navbar.test.js index c26e403c8..59446de52 100644 --- a/test/integration/navbar.test.js +++ b/test/integration/navbar.test.js @@ -1,3 +1,5 @@ +// These tests do not sign in as a user + const SeleniumHelper = require('./selenium-helpers.js'); const { diff --git a/test/integration/project-page.test.js b/test/integration/project-page.test.js index 35112fa7f..ba70d9631 100644 --- a/test/integration/project-page.test.js +++ b/test/integration/project-page.test.js @@ -1,3 +1,5 @@ +// These tests do not sign in with a user + const SeleniumHelper = require('./selenium-helpers.js'); const { @@ -35,7 +37,7 @@ describe('www-integration project-page signed out', () => { afterAll(async () => await driver.quit()); // LOGGED OUT TESTS - + test('Find fullscreen button', async () => { await clickXpath('//div[starts-with(@class, "stage_green-flag-overlay")]'); await clickXpath('//img[contains(@alt, "Enter full screen mode")]'); diff --git a/test/integration/selenium-helpers.js b/test/integration/selenium-helpers.js index 5b7fe163d..fe680daf1 100644 --- a/test/integration/selenium-helpers.js +++ b/test/integration/selenium-helpers.js @@ -29,6 +29,7 @@ class SeleniumHelper { 'getDriver', 'getLogs', 'getSauceDriver', + 'signIn', 'urlMatches', 'waitUntilGone' ]); @@ -148,6 +149,18 @@ class SeleniumHelper { }); } + // must be used on a www page + async signIn (username, password, driver) { + await this.clickXpath('//li[@class="link right login-item"]/a'); + let name = await this.findByXpath('//input[@id="frc-username-1088"]'); + await name.sendKeys(username); + let word = await this.findByXpath('//input[@id="frc-password-1088"]'); + await word.sendKeys(password); + await driver.sleep(500); + await this.clickXpath('//button[contains(@class, "button") and ' + + 'contains(@class, "submit-button") and contains(@class, "white")]'); + } + urlMatches (regex) { return this.driver.wait(until.urlMatches(regex), 1000 * 5); } diff --git a/test/integration/sign-in-and-out.test.js b/test/integration/sign-in-and-out.test.js index d015ab79c..c2c93027f 100644 --- a/test/integration/sign-in-and-out.test.js +++ b/test/integration/sign-in-and-out.test.js @@ -1,3 +1,5 @@ +// These tests sign in with user #0 (no number for the username) + const SeleniumHelper = require('./selenium-helpers.js'); const { diff --git a/test/integration/studios-page.test.js b/test/integration/studios-page.test.js index e5da61d1f..6e3ff8ad7 100644 --- a/test/integration/studios-page.test.js +++ b/test/integration/studios-page.test.js @@ -1,17 +1,33 @@ +// These tests sign in with user #2 and user #3 + import SeleniumHelper from './selenium-helpers.js'; const { findByXpath, - buildDriver + buildDriver, + clickXpath, + clickText, + signIn } = new SeleniumHelper(); let remote = process.env.SMOKE_REMOTE || false; let rootUrl = process.env.ROOT_URL || 'https://scratch.ly'; let studioId = process.env.TEST_STUDIO_ID || 10004360; let studioUrl = rootUrl + '/studios/' + studioId; +let myStuffURL = rootUrl + '/mystuff'; +let rateLimitCheck = process.env.RATE_LIMIT_CHECK || rootUrl; + +// since the usernames end in 2 and 3 we're using username2 and username3 +// username 1 is used in other tests. Hopefully this is not confusing. +let username2 = process.env.SMOKE_USERNAME + '2'; +let username3 = process.env.SMOKE_USERNAME + '3'; +let password = process.env.SMOKE_PASSWORD; + +let promoteStudioURL; +let curatorTab; if (remote){ - jest.setTimeout(60000); + jest.setTimeout(70000); } else { jest.setTimeout(20000); } @@ -52,5 +68,83 @@ describe('studio page while signed out', () => { let descriptionText = await studioDescription.getText(); await expect(descriptionText).toEqual('a description'); }); - +}); + +describe('studio management', () => { + // These tests all start on the curators tab of a studio and signed out + + beforeAll(async () => { + driver = await buildDriver('www-integration studio management'); + await driver.get(rootUrl); + + // create a studio for tests + await signIn(username2, password, driver); + await findByXpath('//span[contains(@class, "profile-name")]'); + await driver.get(rateLimitCheck); + await driver.get(myStuffURL); + await clickXpath('//form[@id="new_studio"]/button[@type="submit"]'); + await findByXpath('//div[@class="studio-tabs"]'); + promoteStudioURL = await driver.getCurrentUrl(); + curatorTab = promoteStudioURL + 'curators'; + }); + + beforeEach(async () => { + await clickXpath('//a[contains(@class, "user-info")]'); + await clickText('Sign out'); + await driver.get(curatorTab); + await findByXpath('//div[@class="studio-tabs"]'); + }); + + afterAll(async () => await driver.quit()); + + test('invite a curator', async () => { + // sign in as user2 + await signIn(username2, password, driver); + await findByXpath('//span[contains(@class, "profile-name")]'); + + // invite user3 to curate + let inviteBox = await findByXpath('//div[@class="studio-adder-row"]/input'); + await inviteBox.sendKeys(username3); + await clickXpath('//div[@class="studio-adder-row"]/button'); + let inviteAlert = await findByXpath('//div[@class="alert-msg"]'); // the confirm alert + let alertText = await inviteAlert.getText(); + let successText = `Curator invite sent to "${username3}"`; + await expect(alertText).toMatch(successText); + }); + + test('accept curator invite', async () => { + // Sign in user3 + await signIn(username3, password, driver); + await findByXpath('//span[contains(@class, "profile-name")]'); + + // accept the curator invite + await clickXpath('//button[@class="studio-invitation-button button"]'); + let acceptSuccess = await findByXpath('//div[contains(@class,"studio-info-box-success")]'); + let acceptSuccessVisible = await acceptSuccess.isDisplayed(); + await expect(acceptSuccessVisible).toBe(true); + }); + + test('promote to manager', async () => { + // sign in as user2 + await signIn(username2, password, driver); + await findByXpath('//span[contains(@class, "profile-name")]'); + // for some reason the user isn't showing up without reloading the page + await driver.get(curatorTab); + + // promote user3 + let user3href = '/users/' + username3; + // click kebab menu on the user tile + let kebabMenuXpath = `//a[@href = "${user3href}"]/` + + 'following-sibling::div[@class="overflow-menu-container"]'; + await clickXpath(kebabMenuXpath + '/button[@class="overflow-menu-trigger"]'); + // click promote + // await clickXpath('//button[@class="promote-menu-button"]'); //<-- I think this will do it + await clickXpath(kebabMenuXpath + '/ul/li/button/span[contains(text(), "Promote")]/..'); + await findByXpath('//div[@class="promote-content"]'); + // await clickXpath(//button[contains(@class="promote-button")]) <-- add this selector to the button + await clickXpath('//div[@class="promote-button-row"]/button/span[contains(text(),"Promote")]/..'); + let promoteSuccess = await findByXpath('//div[contains(@class, "alert-success")]'); + let promoteSuccessVisible = await promoteSuccess.isDisplayed(); + await expect(promoteSuccessVisible).toBe(true); + }); }); diff --git a/test/unit/redux/studio-member-actions.test.js b/test/unit/redux/studio-member-actions.test.js index f242de702..84f8f1616 100644 --- a/test/unit/redux/studio-member-actions.test.js +++ b/test/unit/redux/studio-member-actions.test.js @@ -7,7 +7,8 @@ import { removeCurator, inviteCurator, promoteCurator, - acceptInvitation + acceptInvitation, + transferHost } from '../../../src/views/studio/lib/studio-member-actions'; import {managers, curators} from '../../../src/views/studio/lib/redux-modules'; import {reducers, initialState} from '../../../src/views/studio/studio-redux'; @@ -399,4 +400,26 @@ describe('acceptInvitation', () => { expect(state.studio.invited).toBe(true); expect(state.studio.curator).toBe(false); }); + + describe('transferHost', () => { + beforeEach(() => { + store = configureStore(reducers, { + ...initialState, + studio: { + id: 123123, + managers: 3 + } + }); + }); + + test('transfers the host on success', async () => { + api.mockImplementation((opts, callback) => { + callback(null, {}, {statusCode: 200}); + }); + await store.dispatch(transferHost('password', 'newHostName', 'newHostId')); + const state = store.getState(); + expect(api.mock.calls[0][0].uri).toBe('/studios/123123/transfer/newHostName'); + expect(state.studio.owner).toBe('newHostId'); + }); + }); }); diff --git a/test/unit/redux/studio-permissions.test.js b/test/unit/redux/studio-permissions.test.js index 2b409817c..994cdafb1 100644 --- a/test/unit/redux/studio-permissions.test.js +++ b/test/unit/redux/studio-permissions.test.js @@ -15,6 +15,7 @@ import { selectCanRemoveManager, selectCanPromoteCurators, selectCanRemoveProject, + selectCanTransfer, selectShowCommentsList, selectShowCommentsGloballyOffError, selectShowProjectMuteError, @@ -432,6 +433,30 @@ describe('studio members', () => { expect(selectCanInviteCurators(state)).toBe(expected); }); }); + + describe('can transfer host', () => { + test.each([ + ['admin', true], + ['curator', false], + ['manager', false], + ['creator', true], + ['logged in', false], + ['unconfirmed', false], + ['logged out', false], + ['muted creator', true], // Muted users do not see the transfer UI + ['muted logged in', false] + ])('%s: %s', (role, expected) => { + setStateByRole(role); + state.studio = {...state.studio, managers: 2, classroomId: null}; + // Only admin and host see the option to transfer the current host + expect(selectCanTransfer(state, state.studio.owner)).toBe(expected); + // Nobody sees the option to transfer a manager who is not the host + expect(selectCanTransfer(state, 123)).toBe(false); + // Nobody can transfer classroom studios + state.studio = {...state.studio, classroomId: 1}; + expect(selectCanTransfer(state, state.studio.owner)).toBe(false); + }); + }); }); describe('studio mute errors', () => {