Merge pull request #5497 from LLK/develop

Prepare release branch with new changes from develop
This commit is contained in:
Karishma Chadha 2021-05-26 12:15:34 -04:00 committed by GitHub
commit 0490bd324d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 2989 additions and 939 deletions

View file

@ -69,8 +69,11 @@ aliases:
- *save_build_cache
- store_test_results:
path: test/results
- run:
name: Compress Artifacts
command: tar -cvzf build.tar build
- store_artifacts:
path: build
path: build.tar
- &deploy
<<: *defaults
steps:
@ -85,7 +88,7 @@ aliases:
python3 get-pip.py pip==21.0.1
pip install s3cmd==2.1.0
- run:
name: "deploy to staging"
name: "deploy"
command: |
npm run deploy
- &integration_jest
@ -154,10 +157,8 @@ workflows:
- scratch-www-staging
filters:
branches:
only:
- develop
- /^hotfix\/.*/
- /^release\/.*/
ignore:
- master
- build-production:
context:
- scratch-www-all

View file

@ -270,12 +270,11 @@ async.auto({
fastly.activateVersion(results.version, function (e, resp) {
if (e) throw new Error(e);
process.stdout.write('Successfully configured and activated version ' + resp.number + '\n');
if (process.env.FASTLY_PURGE_ALL) {
fastly.purgeAll(FASTLY_SERVICE_ID, function (error) {
if (error) throw new Error(error);
process.stdout.write('Purged all.\n');
});
}
// purge static-assets using surrogate key
fastly.purgeKey(FASTLY_SERVICE_ID, 'static-assets', function (error) {
if (error) throw new Error(error);
process.stdout.write('Purged static assets.\n');
});
});
}
});

334
package-lock.json generated
View file

@ -16,9 +16,9 @@
}
},
"@babel/cli": {
"version": "7.13.16",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.13.16.tgz",
"integrity": "sha512-cL9tllhqvsQ6r1+d9Invf7nNXg/3BlfL1vvvL/AdH9fZ2l5j0CeBcoq6UjsqHpvyN1v5nXSZgqJZoGeK+ZOAbw==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.14.3.tgz",
"integrity": "sha512-zU4JLvwk32ay1lhhyGfqiRUSPoltVDjhYkA3aQq8+Yby9z30s/EsFw1EPOHxWG9YZo2pAGfgdRNeHZQAYU5m9A==",
"dev": true,
"requires": {
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents",
@ -225,20 +225,20 @@
"dev": true
},
"@babel/core": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.0.tgz",
"integrity": "sha512-8YqpRig5NmIHlMLw09zMlPTvUVMILjqCOtVgu+TVNWEBvy9b5I3RRyhqnrV4hjgEK7n8P9OqvkWJAFmEL6Wwfw==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.3.tgz",
"integrity": "sha512-jB5AmTKOCSJIZ72sd78ECEhuPiDMKlQdDI/4QRI6lzYATx5SSogS1oQA2AoPecRCknm30gHi2l+QVvNUu3wZAg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.12.13",
"@babel/generator": "^7.14.0",
"@babel/generator": "^7.14.3",
"@babel/helper-compilation-targets": "^7.13.16",
"@babel/helper-module-transforms": "^7.14.0",
"@babel/helper-module-transforms": "^7.14.2",
"@babel/helpers": "^7.14.0",
"@babel/parser": "^7.14.0",
"@babel/parser": "^7.14.3",
"@babel/template": "^7.12.13",
"@babel/traverse": "^7.14.0",
"@babel/types": "^7.14.0",
"@babel/traverse": "^7.14.2",
"@babel/types": "^7.14.2",
"convert-source-map": "^1.7.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@ -257,25 +257,25 @@
}
},
"@babel/generator": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.1.tgz",
"integrity": "sha512-TMGhsXMXCP/O1WtQmZjpEYDhCYC9vFhayWZPJSZCGkPJgUqX0rF0wwtrYvnzVxIjcF80tkUertXVk5cwqi5cAQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz",
"integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.1",
"@babel/types": "^7.14.2",
"jsesc": "^2.5.1",
"source-map": "^0.5.0"
}
},
"@babel/helper-function-name": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz",
"integrity": "sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.2.tgz",
"integrity": "sha512-NYZlkZRydxw+YT56IlhIcS8PAhb+FEUiOzuhFTfqDyPmzAhRge6ua0dQYT/Uh0t/EDHq05/i+e5M2d4XvjgarQ==",
"dev": true,
"requires": {
"@babel/helper-get-function-arity": "^7.12.13",
"@babel/template": "^7.12.13",
"@babel/types": "^7.12.13"
"@babel/types": "^7.14.2"
}
},
"@babel/helper-get-function-arity": {
@ -308,9 +308,9 @@
}
},
"@babel/parser": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.1.tgz",
"integrity": "sha512-muUGEKu8E/ftMTPlNp+mc6zL3E9zKWmF5sDHZ5MSsoTP9Wyz64AhEf9kD08xYJ7w6Hdcu8H550ircnPyWSIF0Q==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.3.tgz",
"integrity": "sha512-7MpZDIfI7sUC5zWo2+foJ50CSI5lcqDehZ0lVgIhSi4bFEk94fLAKlF3Q0nzSQQ+ca0lm+O6G9ztKVBeu8PMRQ==",
"dev": true
},
"@babel/template": {
@ -325,25 +325,25 @@
}
},
"@babel/traverse": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.0.tgz",
"integrity": "sha512-dZ/a371EE5XNhTHomvtuLTUyx6UEoJmYX+DT5zBCQN3McHemsuIaKKYqsc/fs26BEkHs/lBZy0J571LP5z9kQA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.2.tgz",
"integrity": "sha512-TsdRgvBFHMyHOOzcP9S6QU0QQtjxlRpEYOy3mcCO5RgmC305ki42aSAmfZEMSSYBla2oZ9BMqYlncBaKmD/7iA==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.12.13",
"@babel/generator": "^7.14.0",
"@babel/helper-function-name": "^7.12.13",
"@babel/generator": "^7.14.2",
"@babel/helper-function-name": "^7.14.2",
"@babel/helper-split-export-declaration": "^7.12.13",
"@babel/parser": "^7.14.0",
"@babel/types": "^7.14.0",
"@babel/parser": "^7.14.2",
"@babel/types": "^7.14.2",
"debug": "^4.1.0",
"globals": "^11.1.0"
}
},
"@babel/types": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.1.tgz",
"integrity": "sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -503,9 +503,9 @@
}
},
"electron-to-chromium": {
"version": "1.3.727",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.727.tgz",
"integrity": "sha512-Mfz4FIB4FSvEwBpDfdipRIrwd6uo8gUDoRDF4QEYb4h4tSuI3ov594OrjU6on042UlFHouIJpClDODGkPcBSbg==",
"version": "1.3.739",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz",
"integrity": "sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==",
"dev": true
},
"semver": {
@ -546,9 +546,9 @@
},
"dependencies": {
"@babel/types": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.1.tgz",
"integrity": "sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -573,9 +573,9 @@
},
"dependencies": {
"@babel/types": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.1.tgz",
"integrity": "sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -591,9 +591,9 @@
}
},
"@babel/helper-module-transforms": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.0.tgz",
"integrity": "sha512-L40t9bxIuGOfpIGA3HNkJhU9qYrf4y5A5LUSw7rGMSn+pcG8dfJ0g6Zval6YJGd2nEjI7oP00fRdnhLKndx6bw==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.2.tgz",
"integrity": "sha512-OznJUda/soKXv0XhpvzGWDnml4Qnwp16GN+D/kZIdLsWoHj05kyu8Rm5kXmMef+rVJZ0+4pSGLkeixdqNUATDA==",
"dev": true,
"requires": {
"@babel/helper-module-imports": "^7.13.12",
@ -602,8 +602,8 @@
"@babel/helper-split-export-declaration": "^7.12.13",
"@babel/helper-validator-identifier": "^7.14.0",
"@babel/template": "^7.12.13",
"@babel/traverse": "^7.14.0",
"@babel/types": "^7.14.0"
"@babel/traverse": "^7.14.2",
"@babel/types": "^7.14.2"
},
"dependencies": {
"@babel/code-frame": {
@ -616,25 +616,25 @@
}
},
"@babel/generator": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.1.tgz",
"integrity": "sha512-TMGhsXMXCP/O1WtQmZjpEYDhCYC9vFhayWZPJSZCGkPJgUqX0rF0wwtrYvnzVxIjcF80tkUertXVk5cwqi5cAQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz",
"integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.1",
"@babel/types": "^7.14.2",
"jsesc": "^2.5.1",
"source-map": "^0.5.0"
}
},
"@babel/helper-function-name": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz",
"integrity": "sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.2.tgz",
"integrity": "sha512-NYZlkZRydxw+YT56IlhIcS8PAhb+FEUiOzuhFTfqDyPmzAhRge6ua0dQYT/Uh0t/EDHq05/i+e5M2d4XvjgarQ==",
"dev": true,
"requires": {
"@babel/helper-get-function-arity": "^7.12.13",
"@babel/template": "^7.12.13",
"@babel/types": "^7.12.13"
"@babel/types": "^7.14.2"
}
},
"@babel/helper-get-function-arity": {
@ -667,9 +667,9 @@
}
},
"@babel/parser": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.1.tgz",
"integrity": "sha512-muUGEKu8E/ftMTPlNp+mc6zL3E9zKWmF5sDHZ5MSsoTP9Wyz64AhEf9kD08xYJ7w6Hdcu8H550ircnPyWSIF0Q==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.3.tgz",
"integrity": "sha512-7MpZDIfI7sUC5zWo2+foJ50CSI5lcqDehZ0lVgIhSi4bFEk94fLAKlF3Q0nzSQQ+ca0lm+O6G9ztKVBeu8PMRQ==",
"dev": true
},
"@babel/template": {
@ -684,25 +684,25 @@
}
},
"@babel/traverse": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.0.tgz",
"integrity": "sha512-dZ/a371EE5XNhTHomvtuLTUyx6UEoJmYX+DT5zBCQN3McHemsuIaKKYqsc/fs26BEkHs/lBZy0J571LP5z9kQA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.2.tgz",
"integrity": "sha512-TsdRgvBFHMyHOOzcP9S6QU0QQtjxlRpEYOy3mcCO5RgmC305ki42aSAmfZEMSSYBla2oZ9BMqYlncBaKmD/7iA==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.12.13",
"@babel/generator": "^7.14.0",
"@babel/helper-function-name": "^7.12.13",
"@babel/generator": "^7.14.2",
"@babel/helper-function-name": "^7.14.2",
"@babel/helper-split-export-declaration": "^7.12.13",
"@babel/parser": "^7.14.0",
"@babel/types": "^7.14.0",
"@babel/parser": "^7.14.2",
"@babel/types": "^7.14.2",
"debug": "^4.1.0",
"globals": "^11.1.0"
}
},
"@babel/types": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.1.tgz",
"integrity": "sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -789,9 +789,9 @@
},
"dependencies": {
"@babel/types": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.1.tgz",
"integrity": "sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -813,15 +813,15 @@
"dev": true
},
"@babel/helper-replace-supers": {
"version": "7.13.12",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.13.12.tgz",
"integrity": "sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.3.tgz",
"integrity": "sha512-Rlh8qEWZSTfdz+tgNV/N4gz1a0TMNwCUcENhMjHTHKp3LseYH5Jha0NSlyTQWMnjbYcwFt+bqAMqSLHVXkQ6UA==",
"dev": true,
"requires": {
"@babel/helper-member-expression-to-functions": "^7.13.12",
"@babel/helper-optimise-call-expression": "^7.12.13",
"@babel/traverse": "^7.13.0",
"@babel/types": "^7.13.12"
"@babel/traverse": "^7.14.2",
"@babel/types": "^7.14.2"
},
"dependencies": {
"@babel/code-frame": {
@ -834,25 +834,25 @@
}
},
"@babel/generator": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.1.tgz",
"integrity": "sha512-TMGhsXMXCP/O1WtQmZjpEYDhCYC9vFhayWZPJSZCGkPJgUqX0rF0wwtrYvnzVxIjcF80tkUertXVk5cwqi5cAQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz",
"integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.1",
"@babel/types": "^7.14.2",
"jsesc": "^2.5.1",
"source-map": "^0.5.0"
}
},
"@babel/helper-function-name": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz",
"integrity": "sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.2.tgz",
"integrity": "sha512-NYZlkZRydxw+YT56IlhIcS8PAhb+FEUiOzuhFTfqDyPmzAhRge6ua0dQYT/Uh0t/EDHq05/i+e5M2d4XvjgarQ==",
"dev": true,
"requires": {
"@babel/helper-get-function-arity": "^7.12.13",
"@babel/template": "^7.12.13",
"@babel/types": "^7.12.13"
"@babel/types": "^7.14.2"
}
},
"@babel/helper-get-function-arity": {
@ -885,9 +885,9 @@
}
},
"@babel/parser": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.1.tgz",
"integrity": "sha512-muUGEKu8E/ftMTPlNp+mc6zL3E9zKWmF5sDHZ5MSsoTP9Wyz64AhEf9kD08xYJ7w6Hdcu8H550ircnPyWSIF0Q==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.3.tgz",
"integrity": "sha512-7MpZDIfI7sUC5zWo2+foJ50CSI5lcqDehZ0lVgIhSi4bFEk94fLAKlF3Q0nzSQQ+ca0lm+O6G9ztKVBeu8PMRQ==",
"dev": true
},
"@babel/template": {
@ -902,25 +902,25 @@
}
},
"@babel/traverse": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.0.tgz",
"integrity": "sha512-dZ/a371EE5XNhTHomvtuLTUyx6UEoJmYX+DT5zBCQN3McHemsuIaKKYqsc/fs26BEkHs/lBZy0J571LP5z9kQA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.2.tgz",
"integrity": "sha512-TsdRgvBFHMyHOOzcP9S6QU0QQtjxlRpEYOy3mcCO5RgmC305ki42aSAmfZEMSSYBla2oZ9BMqYlncBaKmD/7iA==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.12.13",
"@babel/generator": "^7.14.0",
"@babel/helper-function-name": "^7.12.13",
"@babel/generator": "^7.14.2",
"@babel/helper-function-name": "^7.14.2",
"@babel/helper-split-export-declaration": "^7.12.13",
"@babel/parser": "^7.14.0",
"@babel/types": "^7.14.0",
"@babel/parser": "^7.14.2",
"@babel/types": "^7.14.2",
"debug": "^4.1.0",
"globals": "^11.1.0"
}
},
"@babel/types": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.1.tgz",
"integrity": "sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -1007,9 +1007,9 @@
},
"dependencies": {
"@babel/types": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.1.tgz",
"integrity": "sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -1066,25 +1066,25 @@
}
},
"@babel/generator": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.1.tgz",
"integrity": "sha512-TMGhsXMXCP/O1WtQmZjpEYDhCYC9vFhayWZPJSZCGkPJgUqX0rF0wwtrYvnzVxIjcF80tkUertXVk5cwqi5cAQ==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz",
"integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==",
"dev": true,
"requires": {
"@babel/types": "^7.14.1",
"@babel/types": "^7.14.2",
"jsesc": "^2.5.1",
"source-map": "^0.5.0"
}
},
"@babel/helper-function-name": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz",
"integrity": "sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.2.tgz",
"integrity": "sha512-NYZlkZRydxw+YT56IlhIcS8PAhb+FEUiOzuhFTfqDyPmzAhRge6ua0dQYT/Uh0t/EDHq05/i+e5M2d4XvjgarQ==",
"dev": true,
"requires": {
"@babel/helper-get-function-arity": "^7.12.13",
"@babel/template": "^7.12.13",
"@babel/types": "^7.12.13"
"@babel/types": "^7.14.2"
}
},
"@babel/helper-get-function-arity": {
@ -1117,9 +1117,9 @@
}
},
"@babel/parser": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.1.tgz",
"integrity": "sha512-muUGEKu8E/ftMTPlNp+mc6zL3E9zKWmF5sDHZ5MSsoTP9Wyz64AhEf9kD08xYJ7w6Hdcu8H550ircnPyWSIF0Q==",
"version": "7.14.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.3.tgz",
"integrity": "sha512-7MpZDIfI7sUC5zWo2+foJ50CSI5lcqDehZ0lVgIhSi4bFEk94fLAKlF3Q0nzSQQ+ca0lm+O6G9ztKVBeu8PMRQ==",
"dev": true
},
"@babel/template": {
@ -1134,25 +1134,25 @@
}
},
"@babel/traverse": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.0.tgz",
"integrity": "sha512-dZ/a371EE5XNhTHomvtuLTUyx6UEoJmYX+DT5zBCQN3McHemsuIaKKYqsc/fs26BEkHs/lBZy0J571LP5z9kQA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.2.tgz",
"integrity": "sha512-TsdRgvBFHMyHOOzcP9S6QU0QQtjxlRpEYOy3mcCO5RgmC305ki42aSAmfZEMSSYBla2oZ9BMqYlncBaKmD/7iA==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.12.13",
"@babel/generator": "^7.14.0",
"@babel/helper-function-name": "^7.12.13",
"@babel/generator": "^7.14.2",
"@babel/helper-function-name": "^7.14.2",
"@babel/helper-split-export-declaration": "^7.12.13",
"@babel/parser": "^7.14.0",
"@babel/types": "^7.14.0",
"@babel/parser": "^7.14.2",
"@babel/types": "^7.14.2",
"debug": "^4.1.0",
"globals": "^11.1.0"
}
},
"@babel/types": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.1.tgz",
"integrity": "sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==",
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.2.tgz",
"integrity": "sha512-SdjAG/3DikRHpUOjxZgnkbR11xUlyDMUFJdvnIgZEE16mqmY0BINMmc4//JMJglEmn6i7sq6p+mGrFWyZ98EEw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
@ -1369,9 +1369,9 @@
}
},
"@formatjs/intl-getcanonicallocales": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.5.10.tgz",
"integrity": "sha512-tFqGxZ9HkAzphupybyCKdWHzL1ge/sY8TtzEK57Hs3RCxrv/y+VxIPrE+Izw2oCFowQBz76cyi0zT6PjHuWArA==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.7.0.tgz",
"integrity": "sha512-3pQjp+WCHrFfkUeSuw//LIx/orcykopZX4U/2kY1jTYhATVJbKdU8Rl5V2/d+fw1naKjKYoIjIXGn85Ti396+A==",
"dev": true,
"requires": {
"cldr-core": "38",
@ -1387,21 +1387,21 @@
}
},
"@formatjs/intl-locale": {
"version": "2.4.24",
"resolved": "https://registry.npmjs.org/@formatjs/intl-locale/-/intl-locale-2.4.24.tgz",
"integrity": "sha512-+JOwvBRFS/GFuJlWiWbfAzBng0A+ANoGV1LRseXK+4uzp4Sn35GD8M/dfgU1lp2R2dTWpYie2yyoHe4k4aHF6w==",
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@formatjs/intl-locale/-/intl-locale-2.4.28.tgz",
"integrity": "sha512-z20qVhgtHFTCGLDCl/pWs3cdnxGT4whsbjxwfrhyF2Qf0TNYWrJ/y88f3vINJ19iGVY3GJ6bxaRI5d+uyQ/7ig==",
"dev": true,
"requires": {
"@formatjs/ecma402-abstract": "1.7.1",
"@formatjs/intl-getcanonicallocales": "1.5.10",
"@formatjs/ecma402-abstract": "1.9.1",
"@formatjs/intl-getcanonicallocales": "1.7.0",
"cldr-core": "38",
"tslib": "^2.1.0"
},
"dependencies": {
"@formatjs/ecma402-abstract": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.7.1.tgz",
"integrity": "sha512-FjewVLB2DVEVCvvC7IMffzXVhysvi442i6ed0H7qcrT6xtUpO4vr0oZgpOmsv6D9I4Io0GVebIuySwteS/k3gg==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.9.1.tgz",
"integrity": "sha512-XAJ1ygWKgGEaFuNg3Cf+maJNYEJjl5LjSVZ1iAnSaOKDg/VXa+dDPWhWQP6jimvWv6h9NyDj6Zgh+2qFBeVABw==",
"dev": true,
"requires": {
"tslib": "^2.1.0"
@ -1416,19 +1416,19 @@
}
},
"@formatjs/intl-pluralrules": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.18.tgz",
"integrity": "sha512-qRFITPsNoeXfsiGc97pp8mVgqcC7aQNuXsiJjY9LpXVTkYNfjUP4ZpbYXflM4xoWCXMJNz3ilsrQhZWXy9td5g==",
"version": "4.0.22",
"resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.22.tgz",
"integrity": "sha512-4pSxb31AsjZXUjQHid9eJuUJrXqLOH3tgfiryvBfgNoS76cqk0cFUAuTGdC07YQZlVuJ4c3K4rqBlRpFJwn4Mg==",
"dev": true,
"requires": {
"@formatjs/ecma402-abstract": "1.7.1",
"@formatjs/ecma402-abstract": "1.9.1",
"tslib": "^2.1.0"
},
"dependencies": {
"@formatjs/ecma402-abstract": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.7.1.tgz",
"integrity": "sha512-FjewVLB2DVEVCvvC7IMffzXVhysvi442i6ed0H7qcrT6xtUpO4vr0oZgpOmsv6D9I4Io0GVebIuySwteS/k3gg==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.9.1.tgz",
"integrity": "sha512-XAJ1ygWKgGEaFuNg3Cf+maJNYEJjl5LjSVZ1iAnSaOKDg/VXa+dDPWhWQP6jimvWv6h9NyDj6Zgh+2qFBeVABw==",
"dev": true,
"requires": {
"tslib": "^2.1.0"
@ -20856,9 +20856,9 @@
}
},
"scratch-blocks": {
"version": "0.1.0-prerelease.20210512032919",
"resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.20210512032919.tgz",
"integrity": "sha512-h8XAMbgGGZOHnNM4GwAzTsUY/f0ZKfwOdqt/Uh5/puFPC52uuGHkJp+IrTCMrDt4LkTq7uM/aAPyWMR/z2xwIQ==",
"version": "0.1.0-prerelease.20210526033756",
"resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.20210526033756.tgz",
"integrity": "sha512-KI5qN+EUhrqRfyCgW7/on3pZuzGxxpWhnTdsv7t4PS4fpOmjTMWXxFg2bCh0pJcFrOWHCF/SfQZh/fhwtmaDGg==",
"dev": true,
"requires": {
"exports-loader": "0.6.3",
@ -20866,9 +20866,9 @@
}
},
"scratch-gui": {
"version": "0.1.0-prerelease.20210512144423",
"resolved": "https://registry.npmjs.org/scratch-gui/-/scratch-gui-0.1.0-prerelease.20210512144423.tgz",
"integrity": "sha512-EjQmlthZD6WfIkb5XqOEvIdxkzeSWoEHk1gHRF/gvB/cfK5EXzf9ZBM/Q8Qkq9kd/kyddi8D/gNINDHRh4cfdQ==",
"version": "0.1.0-prerelease.20210526041028",
"resolved": "https://registry.npmjs.org/scratch-gui/-/scratch-gui-0.1.0-prerelease.20210526041028.tgz",
"integrity": "sha512-BUgeYEXcs3rbPQb+V93mQX5sXvE2z1Biq2+bSQuWWZzahJKCuLgfwSlBio5T2NTFk0G0QekQmmX5hasvDTPEtw==",
"dev": true,
"requires": {
"arraybuffer-loader": "^1.0.6",
@ -20919,8 +20919,8 @@
"redux": "3.7.2",
"redux-throttle": "0.1.1",
"scratch-audio": "0.1.0-prerelease.20200528195344",
"scratch-blocks": "0.1.0-prerelease.20210512032919",
"scratch-l10n": "3.11.20210512031514",
"scratch-blocks": "0.1.0-prerelease.20210526033756",
"scratch-l10n": "3.11.20210526031609",
"scratch-paint": "0.2.0-prerelease.20210407203313",
"scratch-render": "0.1.0-prerelease.20210325231800",
"scratch-render-fonts": "1.0.0-prerelease.20210401210003",
@ -21089,9 +21089,9 @@
"dev": true
},
"electron-to-chromium": {
"version": "1.3.727",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.727.tgz",
"integrity": "sha512-Mfz4FIB4FSvEwBpDfdipRIrwd6uo8gUDoRDF4QEYb4h4tSuI3ov594OrjU6on042UlFHouIJpClDODGkPcBSbg==",
"version": "1.3.739",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz",
"integrity": "sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==",
"dev": true
},
"has-flag": {
@ -21388,9 +21388,9 @@
}
},
"scratch-l10n": {
"version": "3.11.20210512031514",
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.11.20210512031514.tgz",
"integrity": "sha512-STUFHVKPyCMrfeKV9gLK5rf6SMtS8JB1+nV2Jf/4geSb3mAKfWLHOgRxIjRbHsGRhv2sZ8Z+wE6/zILpnnlNZQ==",
"version": "3.11.20210526031609",
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.11.20210526031609.tgz",
"integrity": "sha512-Lr2d09o92jgBptCA5FfC3U9YXesKhPAVzRTQkydsz7DpQKmMilI81gxP6o5CLzxi85hyzZmvIRGlhRIhfHbgBQ==",
"dev": true,
"requires": {
"@babel/cli": "^7.1.2",
@ -27457,9 +27457,9 @@
}
},
"webpack-bundle-analyzer": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.1.tgz",
"integrity": "sha512-j5m7WgytCkiVBoOGavzNokBOqxe6Mma13X1asfVYtKWM3wxBiRRu1u1iG0Iol5+qp9WgyhkMmBAcvjEfJ2bdDw==",
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.2.tgz",
"integrity": "sha512-PIagMYhlEzFfhMYOzs5gFT55DkUdkyrJi/SxJp8EF3YMWhS+T9vvs2EoTetpk5qb6VsCq02eXTlRDOydRhDFAQ==",
"dev": true,
"requires": {
"acorn": "^8.0.4",
@ -27474,15 +27474,15 @@
},
"dependencies": {
"acorn": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.1.1.tgz",
"integrity": "sha512-xYiIVjNuqtKXMxlRMDc6mZUhXehod4a3gbZ1qRlM7icK4EbxUFNLhWoPblCvFtB2Y9CIqHP3CF/rdxLItaQv8g==",
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.2.4.tgz",
"integrity": "sha512-Ibt84YwBDDA890eDiDCEqcbwvHlBvzzDkU2cGBBDDI1QWT12jTiXIOn2CIw5KK4i6N5Z2HUxwYjzriDyqaqqZg==",
"dev": true
},
"acorn-walk": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.0.2.tgz",
"integrity": "sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.1.0.tgz",
"integrity": "sha512-mjmzmv12YIG/G8JQdQuz2MUDShEJ6teYpT5bmWA4q7iwoGen8xtt3twF3OvzIUl+Q06aWIjvnwQUKvQ6TtMRjg==",
"dev": true
},
"ansi-styles": {
@ -27495,9 +27495,9 @@
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
@ -27547,9 +27547,9 @@
}
},
"ws": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.4.tgz",
"integrity": "sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==",
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz",
"integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==",
"dev": true
}
}

View file

@ -27,7 +27,7 @@
"deploy": "npm run deploy:s3 && npm run deploy:fastly",
"deploy:fastly": "node ./bin/configure-fastly.js",
"deploy:s3": "npm run deploy:s3:all && npm run deploy:s3:svg && npm run deploy:s3:js",
"deploy:s3cmd": "s3cmd sync -P --delete-removed --add-header=Cache-Control:no-cache,public,max-age=3600",
"deploy:s3cmd": "s3cmd sync -P --delete-removed --add-header=Cache-Control:no-cache,public,max-age=3600 --add-header=x-amz-meta-surrogate-key:static-assets",
"deploy:s3:all": "npm run deploy:s3cmd -- --exclude '.DS_Store' --exclude '*.svg' --exclude '*.js' ./build/ s3://$S3_BUCKET_NAME/",
"deploy:s3:svg": "npm run deploy:s3cmd -- --exclude '*' --include '*.svg' --mime-type 'image/svg+xml' ./build/ s3://$S3_BUCKET_NAME/",
"deploy:s3:js": "npm run deploy:s3cmd -- --exclude '*' --include '*.js' --mime-type 'application/javascript' ./build/ s3://$S3_BUCKET_NAME/",
@ -126,7 +126,7 @@
"redux-mock-store": "^1.2.3",
"redux-thunk": "2.0.1",
"sass-loader": "6.0.6",
"scratch-gui": "0.1.0-prerelease.20210512144423",
"scratch-gui": "0.1.0-prerelease.20210526041028",
"scratch-l10n": "latest",
"selenium-webdriver": "3.6.0",
"slick-carousel": "1.6.0",

View file

@ -0,0 +1,41 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import Button from '../../components/forms/button.jsx';
import './alert.scss';
const AlertComponent = ({className, icon, id, values, onClear}) => (
<div className="alert-wrapper">
<div
className={classNames('alert', className)}
>
{icon && <img
className="alert-icon"
src={icon}
/>}
<div className="alert-msg">
<FormattedMessage
id={id}
values={values}
/>
</div>
{onClear && <Button
className="alert-close-button"
isCloseType
onClick={onClear}
/>}
</div>
</div>
);
AlertComponent.propTypes = {
className: PropTypes.string,
icon: PropTypes.string,
id: PropTypes.string.isRequired,
values: PropTypes.shape({}),
onClear: PropTypes.func
};
export default AlertComponent;

View file

@ -0,0 +1,18 @@
import {createContext, useContext} from 'react';
import AlertStatus from './alert-status.js';
const AlertContext = createContext({
// Note: defaults here are only used if there is no Provider in the tree
status: AlertStatus.NONE,
data: {},
clearAlert: () => {},
successAlert: () => {},
errorAlert: () => {}
});
const useAlertContext = () => useContext(AlertContext);
export {
AlertContext as default,
useAlertContext
};

View file

@ -0,0 +1,54 @@
import React, {useRef, useState} from 'react';
import PropTypes from 'prop-types';
import AlertStatus from './alert-status.js';
import AlertContext from './alert-context.js';
const AlertProvider = ({children}) => {
const defaultState = {
status: AlertStatus.NONE,
data: {},
showClear: false
};
const [state, setState] = useState(defaultState);
const timeoutRef = useRef(null);
const clearAlert = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setState(defaultState);
};
const handleAlert = (status, data, timeoutSeconds = 3) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setState({status, data, showClear: !timeoutSeconds});
if (timeoutSeconds) {
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
setState(defaultState);
}, timeoutSeconds * 1000);
}
};
return (
<AlertContext.Provider
value={{
status: state.status,
data: state.data,
showClear: state.showClear,
clearAlert: clearAlert,
successAlert: (newData, timeoutSeconds = 3) =>
handleAlert(AlertStatus.SUCCESS, newData, timeoutSeconds),
errorAlert: (newData, timeoutSeconds = 3) =>
handleAlert(AlertStatus.ERROR, newData, timeoutSeconds)
}}
>
{children}
</AlertContext.Provider>
);
};
AlertProvider.propTypes = {
children: PropTypes.node
};
export default AlertProvider;

View file

@ -0,0 +1,5 @@
export default {
NONE: 'NONE',
SUCCESS: 'SUCCESS',
ERROR: 'ERROR'
};

View file

@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import AlertComponent from './alert-component.jsx';
import AlertStatus from './alert-status.js';
import {useAlertContext} from './alert-context.js';
import successIcon from './icon-alert-success.svg';
import errorIcon from './icon-alert-error.svg';
const Alert = ({className}) => {
const {status, data, showClear, clearAlert} = useAlertContext();
if (status === AlertStatus.NONE) return null;
return (
<AlertComponent
className={classNames(className, {
'alert-success': status === AlertStatus.SUCCESS,
'alert-error': status === AlertStatus.ERROR
})}
icon={status === AlertStatus.SUCCESS ? successIcon : errorIcon}
id={data.id}
values={data.values}
onClear={showClear && clearAlert}
/>
);
};
Alert.propTypes = {
className: PropTypes.string
};
export default Alert;

View file

@ -0,0 +1,40 @@
.alert-wrapper {
position: absolute;
display: flex;
width: 100%;
justify-content: center;
z-index: 100;
pointer-events: none;
.alert {
display: flex;
box-sizing: border-box;
padding: 10px 20px;
border-radius: 8px;
align-items: center;
margin-top: 1rem;
min-height: 60px;
pointer-events: auto;
&.alert-error {
background: #FFF0DF;
border: 1px solid #FF8C1A;
box-shadow: 0px 0px 0px 2px rgba(255, 140, 26, 0.25)
}
&.alert-success {
background: #CEF2E8;
border: 1px solid #0EBD8C;
box-shadow: 0px 0px 0px rgba(14, 189, 140, 0.25);
}
.alert-msg {
font-size: 14px;
font-weight: bold;
}
.alert-close-button {
position: unset;
margin-left: 1rem;
}
}
}

View file

@ -0,0 +1,3 @@
<svg width="28" height="20" viewBox="-2 -1 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.40571 6.50912C1.50472 6.50912 0.775271 7.23857 0.775271 8.13956C0.775271 9.04055 1.50472 9.77 2.40571 9.77C3.3067 9.77 4.03615 9.04055 4.03615 8.13956C4.03615 7.23857 3.3067 6.50912 2.40571 6.50912ZM3.34168 5.02359C2.92699 5.9523 1.88444 5.9523 1.46975 5.02359L0.145744 2.07519C-0.268945 1.15289 0.250665 0 1.08171 0H3.72972C4.56076 0 5.08037 1.15289 4.66568 2.07519L3.34168 5.02359Z" fill="#FF8C1A"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View file

@ -0,0 +1,9 @@
<svg width="28" height="20" viewBox="0 0 28 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.86144 15.403C7.43527 15.403 7.0091 15.2398 6.68447 14.9152L3.48818 11.7189C2.83727 11.068 2.83727 10.0159 3.48818 9.36498C4.13909 8.71407 5.19121 8.71407 5.84212 9.36498L7.86144 11.3843L14.1591 5.08828C14.8084 4.43737 15.8622 4.43737 16.5131 5.08828C17.1623 5.73753 17.1623 6.7913 16.5131 7.44222L9.03841 14.9152C8.71378 15.2398 8.28761 15.403 7.86144 15.403Z" fill="#575E75"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="3" y="4" width="14" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.86144 15.403C7.43527 15.403 7.0091 15.2398 6.68447 14.9152L3.48818 11.7189C2.83727 11.068 2.83727 10.0159 3.48818 9.36498C4.13909 8.71407 5.19121 8.71407 5.84212 9.36498L7.86144 11.3843L14.1591 5.08828C14.8084 4.43737 15.8622 4.43737 16.5131 5.08828C17.1623 5.73753 17.1623 6.7913 16.5131 7.44222L9.03841 14.9152C8.71378 15.2398 8.28761 15.403 7.86144 15.403Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<rect width="20" height="20" fill="#0FBD8C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -8,6 +8,7 @@
background-color: $ui-blue-10percent;
width: 100%;
text-align: center;
box-sizing: border-box;
p {
margin-bottom: 0;

View file

@ -8,6 +8,7 @@ const ToggleSlider = props => (
<label className={classNames('toggle-switch', props.className)} >
<input
checked={props.checked}
disabled={props.disabled}
type="checkbox"
onChange={props.onChange}
/>
@ -17,6 +18,7 @@ const ToggleSlider = props => (
ToggleSlider.propTypes = {
checked: PropTypes.bool,
disabled: PropTypes.bool,
className: PropTypes.string,
onChange: PropTypes.func
};

View file

@ -22,7 +22,7 @@ const ValidationMessage = props => (
ValidationMessage.propTypes = {
className: PropTypes.string,
message: PropTypes.string,
mode: PropTypes.string
mode: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
};
module.exports = ValidationMessage;

View file

@ -135,6 +135,7 @@ class Navigation extends React.Component {
/>
<Input
aria-label={this.props.intl.formatMessage({id: 'general.search'})}
className="search-wrapper"
name="q"
placeholder={this.props.intl.formatMessage({id: 'general.search'})}
type="text"

View file

@ -0,0 +1,9 @@
<svg width="21" height="19" viewBox="0 0 21 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4923 14.0441C11.4621 14.0441 12.2482 14.7672 12.2482 15.659C12.2482 16.5509 11.4621 17.2739 10.4923 17.2739C9.52252 17.2739 8.73636 16.5509 8.73636 15.659C8.73636 14.7672 9.52252 14.0441 10.4923 14.0441ZM10.4923 7.58453C11.4621 7.58453 12.2482 8.30755 12.2482 9.19943C12.2482 10.0913 11.4621 10.8143 10.4923 10.8143C9.52252 10.8143 8.73636 10.0913 8.73636 9.19943C8.73636 8.30755 9.52252 7.58453 10.4923 7.58453ZM12.2482 2.73983C12.2482 1.84795 11.4621 1.12493 10.4923 1.12493C9.52252 1.12493 8.73636 1.84795 8.73636 2.73983C8.73636 3.63172 9.52252 4.35474 10.4923 4.35474C11.4621 4.35474 12.2482 3.63172 12.2482 2.73983Z" fill="#575E75" fill-opacity="0.6"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="8" y="1" width="5" height="17">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4923 14.0441C11.4621 14.0441 12.2482 14.7672 12.2482 15.659C12.2482 16.5509 11.4621 17.2739 10.4923 17.2739C9.52252 17.2739 8.73636 16.5509 8.73636 15.659C8.73636 14.7672 9.52252 14.0441 10.4923 14.0441ZM10.4923 7.58453C11.4621 7.58453 12.2482 8.30755 12.2482 9.19943C12.2482 10.0913 11.4621 10.8143 10.4923 10.8143C9.52252 10.8143 8.73636 10.0913 8.73636 9.19943C8.73636 8.30755 9.52252 7.58453 10.4923 7.58453ZM12.2482 2.73983C12.2482 1.84795 11.4621 1.12493 10.4923 1.12493C9.52252 1.12493 8.73636 1.84795 8.73636 2.73983C8.73636 3.63172 9.52252 4.35474 10.4923 4.35474C11.4621 4.35474 12.2482 3.63172 12.2482 2.73983Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<rect x="0.93222" y="18.1711" width="17.9433" height="19.5104" transform="rotate(-90 0.93222 18.1711)" fill="#575E75" fill-opacity="0.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,45 @@
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Dropdown from '../dropdown/dropdown.jsx';
import overflowIcon from './overflow-icon.svg';
import './overflow-menu.scss';
const OverflowMenu = ({children, dropdownAs, className}) => {
const [open, setOpen] = useState(false);
return (
<div className={classNames('overflow-menu-container', className)}>
<button
className={classNames('overflow-menu-trigger', {
'ignore-react-onclickoutside': open
})}
onClick={() => setOpen(!open)}
>
<img src={overflowIcon} />
</button>
{open && <Dropdown
isOpen
as={dropdownAs}
className="overflow-menu-dropdown"
onRequestClose={() => setOpen(false)}
>
{children}
</Dropdown>}
</div>
);
};
OverflowMenu.propTypes = {
children: PropTypes.node,
dropdownAs: PropTypes.string,
className: PropTypes.string
};
OverflowMenu.defaultProps = {
dropdownAs: 'ul'
};
export default OverflowMenu;

View file

@ -0,0 +1,50 @@
.overflow-menu-container {
display: flex;
position: relative;
.overflow-menu-trigger {
background: transparent;
border: none;
display: flex;
align-items: center;
}
.overflow-menu-dropdown {
border: 1px solid rgba(0, 0, 0, 0.15);
box-sizing: border-box;
box-shadow: 0px 2px 8px rgba(87, 94, 117, 0.5);
border-radius: 8px;
padding: 0;
margin: 30px 0 0 0;
right: unset; /* default dropdown aligns right edges, but we want left edges */
left: 0;
z-index: 1;
/* Include default styling for <li><button />... list */
li {
margin: 0;
padding: 0;
& + li {
border-top: 1px solid rgba(0, 0, 0, 0.15);
}
button {
display: flex;
align-items: center;
color: white;
font-weight: bold;
padding: 5px 10px;
background: none;
border: none;
width: 100%;
text-align: left;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
& > img {
margin: 0 10px 0 0;
}
}
}
}
}

View file

@ -12,7 +12,7 @@
flex-wrap: wrap;
li {
li, button {
display: inline-block;
margin: 5px;
border: 1px solid $active-gray;

View file

@ -127,6 +127,12 @@ module.exports.selectUsername = state => get(state, ['session', 'session', 'user
module.exports.selectToken = state => get(state, ['session', 'session', 'user', 'token'], null);
module.exports.selectIsAdmin = state => get(state, ['session', 'session', 'permissions', 'admin'], false);
module.exports.selectIsSocial = state => get(state, ['session', 'session', 'permissions', 'social'], false);
module.exports.selectIsEducator = state => get(state, ['session', 'session', 'permissions', 'educator'], false);
module.exports.selectMuteStatus = state => get(state, ['session', 'session', 'permissions', 'mute_status'],
{muteExpiresAt: 0, offenses: [], showWarning: false});
module.exports.selectIsMuted = state => (module.exports.selectMuteStatus(state).muteExpiresAt || 0) * 1000 > Date.now();
module.exports.selectHasFetchedSession = state => state.session.status === module.exports.Status.FETCHED;
// NB logged out user id as NaN so that it can never be used in equality testing since NaN !== NaN
module.exports.selectUserId = state => get(state, ['session', 'session', 'user', 'id'], NaN);

View file

@ -18,7 +18,7 @@ const Errors = keyMirror({
INAPPROPRIATE: null,
PERMISSION: null,
THUMBNAIL_TOO_LARGE: null,
THUMBNAIL_MISSING: null,
THUMBNAIL_INVALID: null,
TEXT_TOO_LONG: null,
REQUIRED_FIELD: null,
UNHANDLED: null
@ -111,7 +111,7 @@ const normalizeError = (err, body, res) => {
switch (body.errors[0]) {
case 'inappropriate-generic': return Errors.INAPPROPRIATE;
case 'thumbnail-too-large': return Errors.THUMBNAIL_TOO_LARGE;
case 'thumbnail-missing': return Errors.THUMBNAIL_MISSING;
case 'image-invalid': return Errors.THUMBNAIL_INVALID;
case 'editable-text-too-long': return Errors.TEXT_TOO_LONG;
case 'This field is required.': return Errors.REQUIRED_FIELD;
default: return Errors.UNHANDLED;
@ -194,6 +194,18 @@ const mutateStudioImage = input => ((dispatch, getState) => {
const error = normalizeError(err, body, res);
dispatch(completeMutation('image', error ? currentImage : body.thumbnail_url, error));
});
// Return a promise with the data-url of the uploaded image
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(input.files[0]);
} catch (e) {
reject(e);
}
});
});
const mutateStudioCommentsAllowed = shouldAllow => ((dispatch, getState) => {

View file

@ -1,4 +1,5 @@
const {selectUserId, selectIsAdmin, selectIsSocial, selectIsLoggedIn, selectUsername} = require('./session');
const {selectUserId, selectIsAdmin, selectIsSocial,
selectIsLoggedIn, selectUsername, selectIsMuted} = require('./session');
// Fine-grain selector helpers - not exported, use the higher level selectors below
const isCreator = state => selectUserId(state) === state.studio.owner;
@ -6,11 +7,12 @@ const isCurator = state => state.studio.curator;
const isManager = state => state.studio.manager || isCreator(state);
// Action-based permissions selectors
const selectCanEditInfo = state => selectIsAdmin(state) || isManager(state);
const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isManager(state));
const selectCanAddProjects = state =>
isManager(state) ||
!selectIsMuted(state) &&
(isManager(state) ||
isCurator(state) ||
(selectIsSocial(state) && state.studio.openToAll);
(selectIsSocial(state) && state.studio.openToAll));
// This isn't "canComment" since they could be muted, but comment composer handles that
const selectShowCommentComposer = state => selectIsSocial(state);
@ -26,17 +28,28 @@ const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state);
const selectCanFollowStudio = state => selectIsLoggedIn(state);
// Matching existing behavior, only admin/creator is allowed to toggle comments.
const selectCanEditCommentsAllowed = state => selectIsAdmin(state) || isCreator(state);
const selectCanEditOpenToAll = state => isManager(state);
const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state));
const selectCanEditOpenToAll = state => !selectIsMuted(state) && isManager(state);
const selectShowCuratorInvite = state => !!state.studio.invited;
const selectCanInviteCurators = state => isManager(state);
const selectCanRemoveCurators = state => isManager(state) || selectIsAdmin(state);
const selectShowCuratorInvite = state => !selectIsMuted(state) && !!state.studio.invited;
const selectCanInviteCurators = state => !selectIsMuted(state) && isManager(state);
const selectCanRemoveCurator = (state, username) => {
if (selectIsMuted(state)) return false;
// Admins/managers can remove any curators
if (isManager(state) || selectIsAdmin(state)) return true;
// Curators can remove themselves
if (selectUsername(state) === username) {
return true;
}
return false;
};
const selectCanRemoveManager = (state, managerId) =>
(selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner;
const selectCanPromoteCurators = state => isManager(state);
!selectIsMuted(state) && (selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner;
const selectCanPromoteCurators = state => !selectIsMuted(state) && isManager(state);
const selectCanRemoveProject = (state, creatorUsername, actorId) => {
if (selectIsMuted(state)) return false;
// Admins/managers can remove any projects
if (isManager(state) || selectIsAdmin(state)) return true;
// Project owners can always remove their projects
@ -50,6 +63,15 @@ const selectCanRemoveProject = (state, creatorUsername, actorId) => {
return false;
};
// We should only show the mute errors to muted users who have any permissions related to the content
const selectShowEditMuteError = state => selectIsMuted(state) && (isManager(state) || selectIsAdmin(state));
const selectShowProjectMuteError = state => selectIsMuted(state) &&
(selectIsAdmin(state) ||
isManager(state) ||
isCurator(state) ||
(selectIsSocial(state) && state.studio.openToAll));
const selectShowCuratorMuteError = state => selectIsMuted(state) && (isManager(state) || selectIsAdmin(state));
export {
selectCanEditInfo,
selectCanAddProjects,
@ -63,8 +85,11 @@ export {
selectCanEditOpenToAll,
selectShowCuratorInvite,
selectCanInviteCurators,
selectCanRemoveCurators,
selectCanRemoveCurator,
selectCanRemoveManager,
selectCanPromoteCurators,
selectCanRemoveProject
selectCanRemoveProject,
selectShowEditMuteError,
selectShowProjectMuteError,
selectShowCuratorMuteError
};

View file

@ -29,7 +29,7 @@ const Errors = keyMirror({
const getInitialState = () => ({
status: Status.IDLE,
field: Fields.TITLE,
field: null,
error: null,
isOpen: false
});

View file

@ -3,7 +3,7 @@ const keyMirror = require('keymirror');
const api = require('../lib/api');
const log = require('../lib/log');
const {selectUsername, selectToken} = require('./session');
const {selectUsername, selectToken, selectIsEducator} = require('./session');
const Status = keyMirror({
FETCHED: null,
@ -22,6 +22,9 @@ const getInitialState = () => ({
followers: 0,
owner: null,
// BEWARE: classroomId is only loaded if the user is an educator
classroomId: null,
rolesStatus: Status.NOT_FETCHED,
manager: false,
curator: false,
@ -91,10 +94,15 @@ const selectStudioDescription = state => state.studio.description;
const selectStudioImage = state => state.studio.image;
const selectStudioOpenToAll = state => state.studio.openToAll;
const selectStudioCommentsAllowed = state => state.studio.commentsAllowed;
const selectStudioLastUpdated = state => state.studio.updated;
const selectStudioLoadFailed = state => state.studio.infoStatus === Status.ERROR;
const selectStudioCommentCount = state => state.studio.commentCount;
const selectStudioFollowerCount = state => state.studio.followers;
const selectStudioProjectCount = state => state.studio.projectCount;
const selectIsFetchingInfo = state => state.studio.infoStatus === Status.FETCHING;
const selectIsFollowing = state => state.studio.following;
const selectIsFetchingRoles = state => state.studio.rolesStatus === Status.FETCHING;
const selectClassroomId = state => state.studio.classroomId;
// Thunks
const getInfo = () => ((dispatch, getState) => {
@ -111,7 +119,9 @@ const getInfo = () => ((dispatch, getState) => {
openToAll: body.open_to_all,
commentsAllowed: body.comments_allowed,
updated: new Date(body.history.modified),
commentCount: body.stats.comments,
followers: body.stats.followers,
projectCount: body.stats.projects,
owner: body.owner
}));
});
@ -138,6 +148,14 @@ const getRoles = () => ((dispatch, getState) => {
invited: body.invited
}));
});
// Since the user is now loaded, it's a good time to check if the studio is part of a classroom
if (selectIsEducator(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}));
});
}
});
module.exports = {
@ -158,8 +176,13 @@ module.exports = {
selectStudioImage,
selectStudioOpenToAll,
selectStudioCommentsAllowed,
selectStudioLastUpdated,
selectStudioLoadFailed,
selectStudioCommentCount,
selectStudioFollowerCount,
selectStudioProjectCount,
selectIsFetchingInfo,
selectIsFetchingRoles,
selectIsFollowing
selectIsFollowing,
selectClassroomId
};

View file

@ -301,7 +301,8 @@
"pattern": "^/studios-playground/\\d+(/projects|/curators|/activity|/comments)?/?(\\?.*)?$",
"routeAlias": "/studios-playground/?$",
"view": "studio/studio",
"title": "Studio Playground"
"title": "Studio Playground",
"dynamicMetaTags": true
},
{
"name": "teacher-faq",

View file

@ -12,12 +12,62 @@ const Grid = require('../../components/grid/grid.jsx');
const TextArea = require('../../components/forms/textarea.jsx');
const SubNavigation = require('../../components/subnavigation/subnavigation.jsx');
const Select = require('../../components/forms/select.jsx');
const OverflowMenu = require('../../components/overflow-menu/overflow-menu.jsx').default;
const exampleIcon = require('./example-icon.svg');
const AlertProvider = require('../../components/alert/alert-provider.jsx').default;
const {useAlertContext} = require('../../components/alert/alert-context.js');
const Alert = require('../../components/alert/alert.jsx').default;
require('./components.scss');
/* eslint-disable react/prop-types, react/jsx-no-bind */
/* Demo of how to use the useAlertContext hook */
const AlertButton = ({type, timeoutSeconds}) => {
const {errorAlert, successAlert} = useAlertContext();
const onClick = type === 'success' ?
() => successAlert({id: 'success-alert.string.id'}, timeoutSeconds) :
() => errorAlert({id: 'error-alert.string.id'}, timeoutSeconds);
return (
<Button onClick={onClick}>
{type}, {timeoutSeconds || 'no '} timeout
</Button>
);
};
const Components = () => (
<div className="components">
<div className="inner">
<h1>Alert Provider, Display and Hooks</h1>
<AlertProvider>
<div style={{position: 'relative', minHeight: '200px', border: '1px solid red'}}>
<Alert />
<div><AlertButton
type="success"
timeoutSeconds={3}
/></div>
<div><AlertButton
type="error"
timeoutSeconds={null}
/></div>
</div>
</AlertProvider>
<h1>Overflow Menu</h1>
<div className="example-tile">
<OverflowMenu>
<li>
<button>
<img src={exampleIcon} />
Remove
</button>
</li>
<li>
<button>
<img src={exampleIcon} />
Upgrade!
</button>
</li>
</OverflowMenu>
</div>
<h1>Nav Bubbles</h1>
<div className="subnavigation">
<SubNavigation>

View file

@ -18,6 +18,17 @@
width: 200px;
}
.example-tile {
width: 200px;
height: 50px;
border: 1px solid $ui-border;
border-radius: 8px;
padding: 10px;
display: flex;
justify-content: flex-end;
align-items: center
}
.colors {
span {
display: inline-block;

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 55.1 (78136) - https://sketchapp.com -->
<title>Sound/General/Delete</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M4.54751641,6.99994966 L15.4523042,6.99994966 C15.7284466,6.99994966 15.9523042,7.22380729 15.9523042,7.49994966 C15.9523042,7.51506367 15.9516189,7.5301699 15.9502504,7.54522183 L15.1651793,16.1801783 C15.0715275,17.2102489 14.207924,17.9989808 13.1736049,17.9990897 L6.82662224,17.9997575 C5.79213514,17.9998663 4.92828345,17.2110677 4.83462539,16.180829 L4.04956981,7.54521753 C4.02456905,7.27020922 4.22724022,7.02700381 4.50224854,7.00200306 C4.51729904,7.00063483 4.53240384,6.99994966 4.54751641,6.99994966 Z M7.33333333,4 L7.88603796,2.34188612 C7.95409498,2.13771505 8.14516441,2 8.36037961,2 L11.6396204,2 C11.8548356,2 12.045905,2.13771505 12.113962,2.34188612 L12.6666667,4 L16.5,4 C16.7761424,4 17,4.22385763 17,4.5 L17,5.5 C17,5.77614237 16.7761424,6 16.5,6 L3.5,6 C3.22385763,6 3,5.77614237 3,5.5 L3,4.5 C3,4.22385763 3.22385763,4 3.5,4 L7.33333333,4 Z M8.38742589,4 L11.6125741,4 L11.2792408,3 L8.72075922,3 L8.38742589,4 Z M10,11.7204812 L11.5952436,10.1252376 C11.7905057,9.92997548 12.1070882,9.92997548 12.3023504,10.1252376 L12.3747624,10.1976496 C12.5700245,10.3929118 12.5700245,10.7094943 12.3747624,10.9047564 L10.7795188,12.5 L12.3747624,14.0952436 C12.5700245,14.2905057 12.5700245,14.6070882 12.3747624,14.8023504 L12.3023504,14.8747624 C12.1070882,15.0700245 11.7905057,15.0700245 11.5952436,14.8747624 L10,13.2795188 L8.40475641,14.8747624 C8.20949427,15.0700245 7.89291178,15.0700245 7.69764963,14.8747624 L7.62523762,14.8023504 C7.42997548,14.6070882 7.42997548,14.2905057 7.62523762,14.0952436 L9.22048121,12.5 L7.62523762,10.9047564 C7.42997548,10.7094943 7.42997548,10.3929118 7.62523762,10.1976496 L7.69764963,10.1252376 C7.89291178,9.92997548 8.20949427,9.92997548 8.40475641,10.1252376 L10,11.7204812 Z" id="path-1"></path>
</defs>
<g id="Sound/General/Delete" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Trash-Can"></g>
<g id="White" mask="url(#mask-2)" fill="#FFFFFF">
<rect id="Color" x="0" y="0" width="20" height="20"></rect>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -16,27 +16,28 @@ const Guidelines = () => (
}
>
<p>
<FormattedMessage
className="intro"
id="guidelines.header"
/>
<FormattedMessage id="guidelines.header1" />&nbsp;
<strong><FormattedMessage id="guidelines.header2" /></strong>&nbsp;
<FormattedMessage id="guidelines.header3" />
</p>
<dl>
<dt><FormattedMessage id="guidelines.respectheader" /></dt>
<dd><FormattedMessage id="guidelines.respectbody" /></dd>
<dt><FormattedMessage id="guidelines.constructiveheader" /></dt>
<dd><FormattedMessage id="guidelines.constructivebody" /></dd>
<dt><FormattedMessage id="guidelines.shareheader" /></dt>
<dd><FormattedMessage id="guidelines.sharebody" /></dd>
<dt><FormattedMessage id="guidelines.privacyheader" /></dt>
<dd><FormattedMessage id="guidelines.privacybody" /></dd>
<dt><FormattedMessage id="guidelines.helpfulheader" /></dt>
<dd><FormattedMessage id="guidelines.helpfulbody" /></dd>
<dt><FormattedMessage id="guidelines.remixheader" /></dt>
<dd>
<em><FormattedMessage id="guidelines.remixbody1" /></em><br />
<FormattedMessage id="guidelines.remixbody2" />
</dd>
<dt><FormattedMessage id="guidelines.honestyheader" /></dt>
<dd><FormattedMessage id="guidelines.honestybody" /></dd>
<dt><FormattedMessage id="guidelines.friendlyheader" /></dt>
<dd><FormattedMessage id="guidelines.friendlybody" /></dd>
</dl>
<div className="guidelines-footer">
<p><FormattedMessage id="guidelines.footer" /></p>
<img
alt="sprites"
src="//cdn.scratch.mit.edu/scratchr2/static/images/help/spritesforcommunityguid.png"

View file

@ -1,17 +1,19 @@
{
"guidelines.title": "Scratch Community Guidelines",
"guidelines.header": "We need everyones help to keep Scratch a friendly and creative community where people with different backgrounds and interests feel welcome.",
"guidelines.respectheader": "Be respectful.",
"guidelines.respectbody": "When sharing projects or posting comments, remember that people of many different ages and backgrounds will see what youve shared.",
"guidelines.constructiveheader": "Be constructive.",
"guidelines.constructivebody": "When commenting on others' projects, say something you like about it and offer suggestions.",
"guidelines.shareheader": "Share.",
"guidelines.sharebody": "You are free to remix projects, ideas, images, or anything else you find on Scratch and anyone can use anything that you share. Be sure to give credit when you remix.",
"guidelines.privacyheader": "Keep personal info private.",
"guidelines.privacybody": "For safety reasons, don't give out any information that could be used for private communication - such as real last names, phone numbers, addresses, email addresses, links to social media sites or websites with unmoderated chat.",
"guidelines.header1": "Scratch is a friendly and welcoming community for everyone, where people create, share, and learn together.",
"guidelines.header2": "We welcome people of all ages, races, ethnicities, religions, abilities, sexual orientations, and gender identities.",
"guidelines.header3": "Help keep Scratch a welcoming, supportive, and creative space for all by following these Community Guidelines:",
"guidelines.respectheader": "Treat everyone with respect.",
"guidelines.respectbody": "Scratchers have diverse backgrounds, interests, identities, and experiences. Everyone on Scratch is encouraged to share things that excite them and are important to them—we hope that you find ways to celebrate your own identity on Scratch, and allow others to do the same. Its never OK to attack a person or groups identity or to be unkind to someone about their background or interests.",
"guidelines.privacyheader": "Be safe: keep personal and contact information private.",
"guidelines.privacybody": "For safety reasons, don't give out any information that could be used for private communication, in person or online. This includes sharing real last names, phone numbers, addresses, hometowns, school names, email addresses, usernames or links to social media sites, video chatting applications, or websites with private chat functionality.",
"guidelines.helpfulheader": "Give helpful feedback.",
"guidelines.helpfulbody": "Everyone on Scratch is learning. When commenting on a project, remember to say something you like about it, offer suggestions, and be kind, not critical. Please keep comments respectful and avoid spamming or posting chain mail. We encourage you to try new things, experiment, and learn from others.",
"guidelines.remixheader": "Embrace remix culture.",
"guidelines.remixbody1": "Remixing is when you build upon someone elses projects, code, ideas, images, or anything else they share on Scratch to make your own unique creation.",
"guidelines.remixbody2": "Remixing is a great way to collaborate and connect with other Scratchers. You are encouraged to use anything you find on Scratch in your own creations, as long as you provide credit to everyone whose work you used and make a meaningful change to it. And when you share something on Scratch, you are giving permission to all Scratchers to use your work in their creations, too.",
"guidelines.honestyheader": "Be honest.",
"guidelines.honestybody": "Dont try to impersonate other Scratchers, spread rumors, or otherwise try to trick the community.",
"guidelines.honestybody": "Its important to be honest and authentic when interacting with others on Scratch, and remember that there is a person behind every Scratch account. Spreading rumors, impersonating other Scratchers or celebrities, or pretending to be seriously ill is not respectful to the Scratch Community.",
"guidelines.friendlyheader": "Help keep the site friendly.",
"guidelines.friendlybody": "If you think a project or comment is mean, insulting, too violent, or otherwise inappropriate, click “Report” to let us know about it.",
"guidelines.footer": "Scratch welcomes people of all ages, races, ethnicities, religions, abilities, sexual orientations, and gender identities."
"guidelines.friendlybody": "Its important to keep your creations and conversations friendly and appropriate for all ages. If you think something on Scratch is mean, insulting, too violent, or otherwise disruptive to the community, click “Report” to let us know about it. Please use the “Report” button rather than engaging in fights, spreading rumors about other peoples behavior, or otherwise responding to any inappropriate content. The Scratch Team will look at your report and take the appropriate action."
}

View file

@ -159,6 +159,10 @@
.avatar {
margin-right: .5rem;
border-radius: 4px;
box-shadow: 0px 0px 0px 1px rgba(77, 151, 255, 0.25);
width: 3rem;
height: 3rem;
}
.comment-body {

View file

@ -17,6 +17,7 @@ const formatTime = require('../../../lib/format-time');
const connect = require('react-redux').connect;
const api = require('../../../lib/api');
const {selectMuteStatus} = require('../../../redux/session.js');
require('./comment.scss');
@ -444,9 +445,7 @@ ComposeComment.propTypes = {
};
const mapStateToProps = state => ({
muteStatus: state.session.session.permissions.mute_status ?
state.session.session.permissions.mute_status :
{muteExpiresAt: 0, offenses: [], showWarning: false},
muteStatus: selectMuteStatus(state),
user: state.session.session.user
});

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.70008 9.60002C8.42098 7.95136 9.87058 6.64641 11.7481 6.17829C14.9634 5.37663 18.2197 7.33326 19.0214 10.5485C19.8231 13.7638 17.8664 17.0202 14.6512 17.8218C11.9437 18.4969 9.20407 17.2157 7.92938 14.8716C7.61277 14.2893 6.88413 14.074 6.3019 14.3906C5.71967 14.7072 5.50434 15.4359 5.82094 16.0181C7.60517 19.2993 11.4374 21.0966 15.2318 20.1506C19.7332 19.0282 22.4724 14.4693 21.3501 9.96792C20.2278 5.46653 15.6689 2.72726 11.1675 3.84958C8.91622 4.41089 7.10663 5.8319 5.99998 7.67313V6.00002C5.99998 5.33728 5.46272 4.80002 4.79998 4.80002C4.13723 4.80002 3.59998 5.33728 3.59998 6.00002V10.8C3.59998 11.4628 4.13723 12 4.79998 12H9.59998C10.2627 12 10.8 11.4628 10.8 10.8C10.8 10.1373 10.2627 9.60002 9.59998 9.60002H7.70008Z" fill="white"/>
<path d="M13.2 7.80002C13.5313 7.80002 13.8 8.06865 13.8 8.40002V11.5938L16.4228 12.6429C16.7305 12.766 16.8801 13.1152 16.7571 13.4229C16.634 13.7305 16.2848 13.8802 15.9771 13.7571L12.9771 12.5571C12.7493 12.466 12.6 12.2454 12.6 12V8.40002C12.6 8.06865 12.8686 7.80002 13.2 7.80002Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.4 11.6548C20.4 15.4381 16.6448 18.5003 12 18.5003C11.4006 18.5003 10.8107 18.4535 10.2488 18.3505L7.00594 20.1059C6.41785 20.4242 6.02835 20.1523 6.13619 19.4972L6.56854 16.8709C4.75182 15.6254 3.59998 13.7525 3.59998 11.6548C3.59998 7.87156 7.36452 4.79999 12 4.79999C16.6448 4.79999 20.4 7.87156 20.4 11.6548Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 484 B

View file

@ -0,0 +1,4 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.2531 7.47107C5.2531 8.16594 5.51512 8.80522 5.9491 9.28437C4.50749 10.1253 3.54431 11.8131 3.54431 13.7407C3.54431 14.5562 4.19249 15.1128 5.00253 15.4536C5.82565 15.7998 6.90529 15.9673 7.97081 15.9673C9.03633 15.9673 10.116 15.7998 10.9391 15.4536C11.7491 15.1128 12.3973 14.5562 12.3973 13.7407V13.7342C12.3973 13.6496 12.3973 13.5472 12.3834 13.4418C12.3334 12.4083 12.0039 11.4543 11.4801 10.6824C11.0859 10.1059 10.5795 9.62615 9.99447 9.28522C10.4318 8.80663 10.6887 8.1706 10.6887 7.47107C10.6887 5.96646 9.47307 4.75 7.97416 4.75C6.46912 4.75 5.2531 5.96602 5.2531 7.47107Z" fill="#FFFFFF"/>
<path d="M15.0868 5.71516C14.0615 5.71516 13.2332 6.54343 13.2332 7.56872C13.2332 8.00598 13.385 8.40622 13.6373 8.72277C13.1654 9.02039 12.7758 9.45561 12.5108 9.97982C12.4698 10.0609 12.476 10.1578 12.527 10.233C13.0003 10.9304 13.3012 11.7992 13.3451 12.7461C13.3501 12.8539 13.4236 12.9463 13.5274 12.9753C14.3677 13.2105 15.4588 13.2317 16.3459 13.0414C16.7892 12.9463 17.2012 12.7943 17.5086 12.5735C17.8194 12.3501 18.0443 12.0384 18.0443 11.637C18.0443 10.393 17.4408 9.29929 16.5294 8.72307C16.7819 8.40648 16.9338 8.00612 16.9338 7.56872C16.9338 6.5441 16.1062 5.71516 15.0868 5.71516Z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0592 15.9577C9.96446 16.0524 9.85758 16.1132 9.72976 16.1709L7.56659 17.0793C7.48799 16.8113 7.29336 16.4707 6.93361 16.1109C6.58592 15.7633 6.25738 15.5807 5.99914 15.4901L6.89672 13.3378C6.94337 13.199 7.0271 13.0934 7.1208 12.9997C7.14262 12.9998 7.16333 12.9791 7.17418 12.9682L7.20476 12.9376C7.35075 12.7917 7.55585 12.7028 7.79617 12.6709C8.40705 12.5953 9.11521 12.8852 9.64935 13.4193C10.1835 13.9535 10.4734 14.6616 10.3857 15.2605C10.3505 15.458 10.2854 15.6613 10.1833 15.8116C10.1529 15.864 10.1114 15.9055 10.069 15.9479L10.0592 15.9577ZM13.6054 6.53701C13.9427 6.19967 14.4603 6.12739 14.9902 6.28635C15.2973 6.37845 15.4255 6.75916 15.2154 6.96926L10.177 12.0077C10.0566 12.128 9.86135 12.1478 9.70333 12.047C9.54003 11.9449 9.37127 11.857 9.20119 11.7836C8.92376 11.664 8.82736 11.315 9.02168 11.1207L13.6054 6.53701ZM16.5317 9.46328L11.9489 14.046C11.7586 14.2364 11.4084 14.1545 11.2923 13.8845C11.2173 13.705 11.1239 13.5307 11.0163 13.362C10.9099 13.1964 10.9364 12.99 11.0616 12.8648L16.0882 7.83815C16.2964 7.63003 16.677 7.75614 16.7711 8.06339C16.9356 8.59871 16.871 9.12397 16.5317 9.46328ZM18.5347 4.534C17.2459 3.24525 15.3869 3.01376 14.3838 4.01691L6.29247 12.1082C6.09421 12.3065 5.91898 12.5278 5.81284 12.7984L4.10642 16.8959C3.89183 17.4132 4.05796 18.0587 4.53397 18.5347C5.00998 19.0107 5.65549 19.1769 6.1728 18.9623L10.2703 17.2558C10.5398 17.1486 10.7611 16.9734 10.9594 16.7751L19.0507 8.68382C20.0538 7.68067 19.8234 5.82274 18.5347 4.534Z" fill="#4C97FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 6C13 7.65685 11.6569 9 10 9C8.34315 9 7 7.65685 7 6C7 4.34315 8.34315 3 10 3C11.6569 3 13 4.34315 13 6ZM3.66 9C4.76457 9 5.66 8.10457 5.66 7C5.66 5.89543 4.76457 5 3.66 5C2.55543 5 1.66 5.89543 1.66 7C1.66 8.10457 2.55543 9 3.66 9ZM5.27807 14.6538C4.75697 14.8738 4.14912 15 3.5 15C1.567 15 0 13.8807 0 12.5C0 11.1193 1.567 10 3.5 10C4.69639 10 5.75257 10.4288 6.3838 11.0829C7.29425 10.4157 8.57778 10 10 10C11.4222 10 12.7058 10.4157 13.6162 11.0829C14.2474 10.4288 15.3036 10 16.5 10C18.433 10 20 11.1193 20 12.5C20 13.8807 18.433 15 16.5 15C15.8509 15 15.243 14.8738 14.7219 14.6538C14.0407 16.0199 12.1839 17 10 17C7.81612 17 5.95925 16.0199 5.27807 14.6538ZM16.66 9C17.7646 9 18.66 8.10457 18.66 7C18.66 5.89543 17.7646 5 16.66 5C15.5554 5 14.66 5.89543 14.66 7C14.66 8.10457 15.5554 9 16.66 9Z" fill="#575E75"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 2C5 1.44772 5.44772 1 6 1C6.55228 1 7 1.44772 7 2V3C7 3.55228 6.55228 4 6 4C5.44772 4 5 3.55228 5 3V2ZM12 2H8V3C8 4.10457 7.10457 5 6 5C4.89543 5 4 4.10457 4 3V2C2.34315 2 1 3.34315 1 5V16C1 17.6569 2.34315 19 4 19H16C17.6569 19 19 17.6569 19 16V5C19 3.34315 17.6569 2 16 2V3C16 4.10457 15.1046 5 14 5C12.8954 5 12 4.10457 12 3V2ZM3 15.6V8H17V15.6C17 16.4284 16.3284 17.1 15.5 17.1H4.5C3.67157 17.1 3 16.4284 3 15.6ZM14 1C13.4477 1 13 1.44772 13 2V3C13 3.55228 13.4477 4 14 4C14.5523 4 15 3.55228 15 3V2C15 1.44772 14.5523 1 14 1Z" fill="#575E75"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.8 10.812C20.1312 10.812 20.4 10.5432 20.4 10.212V6.6C20.4 6.2688 20.1312 6 19.8 6H11.0484C10.8888 6 10.7364 6.0636 10.6248 6.1752L9.77518 7.0248C9.66358 7.1364 9.51118 7.2 9.35158 7.2H7.44838C7.28878 7.2 7.13638 7.1364 7.02478 7.0248L6.17518 6.1752C6.06358 6.0636 5.91118 6 5.75158 6H4.19998C3.86878 6 3.59998 6.2688 3.59998 6.6V10.212C3.59998 10.5432 3.86878 10.812 4.19998 10.812H5.76358C5.92318 10.812 6.07438 10.8744 6.18718 10.9872L7.02478 11.8248C7.13638 11.9364 7.28878 12 7.44838 12H9.35158C9.51118 12 9.66358 11.9364 9.77518 11.8248L10.6128 10.9872C10.7244 10.8744 10.8768 10.812 11.0364 10.812H19.8ZM17.4 16.812C17.7312 16.812 18 16.5432 18 16.212V12.6C18 12.2688 17.7312 12 17.4 12H11.0484C10.8888 12 10.7364 12.0636 10.6248 12.1752L9.77518 13.0248C9.66358 13.1364 9.51118 13.2 9.35158 13.2H7.44838C7.28878 13.2 7.13638 13.1364 7.02478 13.0248L6.17518 12.1752C6.06358 12.0636 5.91118 12 5.75158 12H4.19998C3.86878 12 3.59998 12.2688 3.59998 12.6V16.212C3.59998 16.5432 3.86878 16.812 4.19998 16.812H5.76358C5.92318 16.812 6.07438 16.8744 6.18718 16.9872L7.02478 17.8248C7.13638 17.9364 7.28878 18 7.44838 18H9.35158C9.51118 18 9.66358 17.9364 9.77518 17.8248L10.6128 16.9872C10.7244 16.8744 10.8768 16.812 11.0364 16.812H17.4Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 55.1 (78136) - https://sketchapp.com -->
<title>Sound/General/Delete</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M4.54751641,6.99994966 L15.4523042,6.99994966 C15.7284466,6.99994966 15.9523042,7.22380729 15.9523042,7.49994966 C15.9523042,7.51506367 15.9516189,7.5301699 15.9502504,7.54522183 L15.1651793,16.1801783 C15.0715275,17.2102489 14.207924,17.9989808 13.1736049,17.9990897 L6.82662224,17.9997575 C5.79213514,17.9998663 4.92828345,17.2110677 4.83462539,16.180829 L4.04956981,7.54521753 C4.02456905,7.27020922 4.22724022,7.02700381 4.50224854,7.00200306 C4.51729904,7.00063483 4.53240384,6.99994966 4.54751641,6.99994966 Z M7.33333333,4 L7.88603796,2.34188612 C7.95409498,2.13771505 8.14516441,2 8.36037961,2 L11.6396204,2 C11.8548356,2 12.045905,2.13771505 12.113962,2.34188612 L12.6666667,4 L16.5,4 C16.7761424,4 17,4.22385763 17,4.5 L17,5.5 C17,5.77614237 16.7761424,6 16.5,6 L3.5,6 C3.22385763,6 3,5.77614237 3,5.5 L3,4.5 C3,4.22385763 3.22385763,4 3.5,4 L7.33333333,4 Z M8.38742589,4 L11.6125741,4 L11.2792408,3 L8.72075922,3 L8.38742589,4 Z M10,11.7204812 L11.5952436,10.1252376 C11.7905057,9.92997548 12.1070882,9.92997548 12.3023504,10.1252376 L12.3747624,10.1976496 C12.5700245,10.3929118 12.5700245,10.7094943 12.3747624,10.9047564 L10.7795188,12.5 L12.3747624,14.0952436 C12.5700245,14.2905057 12.5700245,14.6070882 12.3747624,14.8023504 L12.3023504,14.8747624 C12.1070882,15.0700245 11.7905057,15.0700245 11.5952436,14.8747624 L10,13.2795188 L8.40475641,14.8747624 C8.20949427,15.0700245 7.89291178,15.0700245 7.69764963,14.8747624 L7.62523762,14.8023504 C7.42997548,14.6070882 7.42997548,14.2905057 7.62523762,14.0952436 L9.22048121,12.5 L7.62523762,10.9047564 C7.42997548,10.7094943 7.42997548,10.3929118 7.62523762,10.1976496 L7.69764963,10.1252376 C7.89291178,9.92997548 8.20949427,9.92997548 8.40475641,10.1252376 L10,11.7204812 Z" id="path-1"></path>
</defs>
<g id="Sound/General/Delete" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Trash-Can"></g>
<g id="White" mask="url(#mask-2)" fill="#FFFFFF">
<rect id="Color" x="0" y="0" width="20" height="20"></rect>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,9 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4057 11.5091C9.50472 11.5091 8.77527 12.2386 8.77527 13.1396C8.77527 14.0405 9.50472 14.77 10.4057 14.77C11.3067 14.77 12.0362 14.0405 12.0362 13.1396C12.0362 12.2386 11.3067 11.5091 10.4057 11.5091ZM11.3417 10.0236C10.927 10.9523 9.88444 10.9523 9.46975 10.0236L8.14574 7.07519C7.73106 6.15289 8.25066 5 9.08171 5H11.7297C12.5608 5 13.0804 6.15289 12.6657 7.07519L11.3417 10.0236Z" fill="white"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="8" y="5" width="5" height="10">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4057 11.5091C9.50472 11.5091 8.77527 12.2386 8.77527 13.1396C8.77527 14.0405 9.50472 14.77 10.4057 14.77C11.3067 14.77 12.0362 14.0405 12.0362 13.1396C12.0362 12.2386 11.3067 11.5091 10.4057 11.5091ZM11.3417 10.0236C10.927 10.9523 9.88444 10.9523 9.46975 10.0236L8.14574 7.07519C7.73106 6.15289 8.25066 5 9.08171 5H11.7297C12.5608 5 13.0804 6.15289 12.6657 7.07519L11.3417 10.0236Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<rect width="20" height="20" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,22 +1,38 @@
{
"studio.tabNavProjects": "Projects",
"studio.tabNavProjectsWithCount": "Projects {projectCount}",
"studio.tabNavCurators": "Curators",
"studio.tabNavComments": "Comments",
"studio.tabNavCommentsWithCount": "Comments {commentCount}",
"studio.tabNavActivity": "Activity",
"studio.title": "Title",
"studio.description": "Description",
"studio.thumbnail": "Thumbnail",
"studio.updateErrors.generic": "Something went wrong updating the studio.",
"studio.updateErrors.inappropriate": "That seems inappropriate. Please be respectful.",
"studio.updateErrors.textTooLong": "That is too long.",
"studio.updateErrors.requiredField": "This cannot be blank.",
"studio.updateErrors.thumbnailTooLarge": "Maximum file size is 512 KB and less than 500x500 pixels.",
"studio.updateErrors.thumbnailInvalid": "Upload a valid image. The file you uploaded was either not an image or a corrupted image.",
"studio.projectsHeader": "Projects",
"studio.addProjectsHeader": "Add Projects",
"studio.addProject": "Add",
"studio.addProjectPlaceholder": "Project URL",
"studio.openToAll": "Anyone can add projects",
"studio.projectsEmptyCanAdd1": "Your studio is looking a little empty.",
"studio.projectsEmptyCanAdd2": "Add your first project!",
"studio.projectsEmpty1": "This studio has no projects yet.",
"studio.projectsEmpty2": "Suggest projects you want to add in the comments!",
"studio.browseProjects": "Browse Projects",
"studio.projectErrors.checkUrl": "Could not find that project. Check the URL and try again.",
"studio.projectErrors.generic": "Could not add project.",
"studio.projectErrors.tooFast": "You are adding projects too quickly.",
"studio.projectErrors.permission": "You do not have permission to add that project.",
"studio.projectErrors.duplicate": "That project is already in this studio.",
"studio.creatorRole": "Studio Creator",
@ -25,24 +41,66 @@
"studio.unfollowStudio": "Unfollow Studio",
"studio.followStudio": "Follow Studio",
"studio.editThumbnail": "Edit Thumbnail",
"studio.curatorsHeader": "Curators",
"studio.inviteCuratorsHeader": "Invite Curators",
"studio.inviteCurator": "Invite",
"studio.inviteCuratorPlaceholder": "Scratch Username",
"studio.curatorInvitationAccepted": "Congratulations! You are now a curator of this studio.",
"studio.curatorInvitation": "Youve been invited to become a curator of this studio.",
"studio.curatorAcceptInvite": "Accept Invite",
"studio.curatorInvitationError": "Something went wrong, try again later.",
"studio.curatorsEmptyCanAdd1": "You dont have curators right now.",
"studio.curatorsEmptyCanAdd2": "Add some curators to collaborate with!",
"studio.curatorsEmpty1": "This studio has no curators right now.",
"studio.curatorErrors.generic": "Could not invite curator.",
"studio.curatorErrors.alreadyCurator": "They are already part of the studio.",
"studio.curatorErrors.unknownUsername": "Could not invite a curator with that username.",
"studio.curatorErrors.tooFast": "You are adding curators too fast.",
"studio.remove": "Remove",
"studio.promote": "Promote",
"studio.commentsHeader": "Comments",
"studio.comments.toggleOff": "Commenting off",
"studio.comments.toggleOn": "Commenting on",
"studio.comments.turnedOff": "Sorry, comment posting has been turned off for this studio.",
"studio.sharedFilter": "Shared",
"studio.favoritedFilter": "Favorited",
"studio.recentFilter": "Recent",
"studio.studentsFilter": "Students",
"studio.activityHeader": "Activity",
"studio.activityAddProjectToStudio": "{profileLink} added the project {projectLink}",
"studio.activityRemoveProjectStudio": "{profileLink} removed the project {projectLink}",
"studio.activityUpdateStudio": "{profileLink} made edits to the title, thumbnail, or description",
"studio.activityBecomeCurator": "{newCuratorProfileLink} accepted an invitation from {inviterProfileLink} to curate this studio",
"studio.activityRemoveCurator": "{removerProfileLink} removed the curator {removedProfileLink}",
"studio.activityBecomeOwner": "{promotedProfileLink} was promoted to manager by {promotorProfileLink}"
"studio.activityBecomeOwner": "{promotedProfileLink} was promoted to manager by {promotorProfileLink}",
"studio.lastUpdated": "Updated {lastUpdatedDate, date, medium}",
"studio.followerCount": "{followerCount} followers",
"studio.reportThisStudio": "Report this studio",
"studio.reportPleaseExplain": "Please select which part of the studio you find to be disrespectful or inappropriate, or otherwise breaks the Scratch Community Guidelines.",
"studio.reportAreThereComments": "Are there inappropriate comments in the studio? Please report them by clicking the \"report\" button on the individual comments.",
"studio.reportThanksForLettingUsKnow": "Thanks for letting us know!",
"studio.reportYourFeedback": "Your feedback will help us make Scratch better.",
"studios.mutedCurators": "You will be able to invite curators and add managers again {inDuration}.",
"studios.mutedProjects": "You will be able to add and remove projects again {inDuration}.",
"studios.mutedEdit": "You will be able to edit studios again {inDuration}.",
"studios.mutedPaused": "Your account has been paused from using studios until then.",
"studio.alertProjectAdded": "\"{title}\" added to studio",
"studio.alertProjectAlreadyAdded": "That project is already in this studio",
"studio.alertProjectRemoveError": "Something went wrong removing the project",
"studio.alertProjectAddError": "Something went wrong adding the project",
"studio.alertCuratorAlreadyInvited": "\"{name}\" has already been invited",
"studio.alertCuratorInvited": "Curator invite sent to \"{name}\"",
"studio.alertManagerPromote": "\"{name}\" is now a manager",
"studio.alertManagerPromoteError": "Something went wrong promoting \"{name}\"",
"studio.alertMemberRemoveError": "Something went wrong removing \"{name}\""
}

View file

@ -110,6 +110,7 @@ const removeCurator = username => ((dispatch, getState) => new Promise((resolve,
const inviteCurator = username => ((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const studioId = selectStudioId(state);
username = username.trim();
api({
uri: `/site-api/users/curators-in/${studioId}/invite_curator/`,
method: 'PUT',
@ -120,8 +121,6 @@ const inviteCurator = username => ((dispatch, getState) => new Promise((resolve,
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
// eslint-disable-next-line no-alert
alert(`successfully invited ${username}`);
return resolve(username);
});
}));
@ -168,7 +167,9 @@ const acceptInvitation = () => ((dispatch, getState) => new Promise((resolve, re
// Note: this assumes that the user items from the curator endpoint
// are the same structure as the single user data returned from /users/:username
dispatch(curators.actions.create(userBody, true));
dispatch(setRoles({invited: false, curator: true}));
setTimeout(() => {
dispatch(setRoles({invited: false, curator: true}));
}, 5 * 1000);
return resolve();
});
});

View file

@ -11,13 +11,15 @@ const Errors = keyMirror({
SERVER: null,
PERMISSION: null,
UNKNOWN_PROJECT: null,
RATE_LIMIT: null
RATE_LIMIT: null,
DUPLICATE: null
});
const normalizeError = (err, body, res) => {
if (err) return Errors.NETWORK;
if (res.statusCode === 401 || res.statusCode === 403) return Errors.PERMISSION;
if (res.statusCode === 404) return Errors.UNKNOWN_PROJECT;
if (res.statusCode === 409) return Errors.DUPLICATE;
if (res.statusCode === 429) return Errors.RATE_LIMIT;
if (res.statusCode !== 200) return Errors.SERVER;
return null;
@ -59,11 +61,25 @@ const generateProjectListItem = (postBody, infoBody) => ({
username: infoBody.author.username,
avatar: infoBody.author.profile.images
});
const addProject = projectId => ((dispatch, getState) => new Promise((resolve, reject) => {
const addProject = projectIdOrUrl => ((dispatch, getState) => new Promise((resolve, reject) => {
// Strings are passed by the open input, numbers by the project browser
let projectId = projectIdOrUrl;
if (typeof projectIdOrUrl === 'string') {
const matches = projectIdOrUrl.match(/(\d+)/g);
if (!matches) return reject(Errors.UNKNOWN_PROJECT);
// Take the last match, in case we are on localhost and there are port numbers, e.g.
projectId = parseInt(matches[matches.length - 1], 10);
}
const state = getState();
const studioId = selectStudioId(state);
const token = selectToken(state);
// Check for existing duplicates before going to the server
if (projects.selector(state).items.filter(p => p.id === projectId).length !== 0) {
return reject(Errors.DUPLICATE);
}
api({
uri: `/studios/${studioId}/project/${projectId}`,
method: 'POST',

View file

@ -1,6 +1,7 @@
import keyMirror from 'keymirror';
import api from '../../../lib/api';
import {selectUsername} from '../../../redux/session';
import {selectToken, selectUsername} from '../../../redux/session';
import {selectClassroomId} from '../../../redux/studio';
import {userProjects, projects} from './redux-modules';
const Errors = keyMirror({
@ -12,13 +13,25 @@ const Errors = keyMirror({
const Filters = keyMirror({
SHARED: null,
FAVORITED: null,
RECENT: null
RECENT: null,
STUDENTS: null
});
const Uris = {
[Filters.SHARED]: username => `/users/${username}/projects`,
[Filters.FAVORITED]: username => `/users/${username}/favorites`,
[Filters.RECENT]: username => `/users/${username}/recent`
const Endpoints = {
[Filters.SHARED]: state => ({
uri: `/users/${selectUsername(state)}/projects`
}),
[Filters.FAVORITED]: state => ({
uri: `/users/${selectUsername(state)}/favorites`
}),
[Filters.RECENT]: state => ({
uri: `/users/${selectUsername(state)}/projects/recentlyviewed`,
authentication: selectToken(state)
}),
[Filters.STUDENTS]: state => ({
uri: `/classrooms/${selectClassroomId(state)}/projects`,
authentication: selectToken(state)
})
};
const normalizeError = (err, body, res) => {
@ -30,14 +43,17 @@ const normalizeError = (err, body, res) => {
const loadUserProjects = type => ((dispatch, getState) => {
const state = getState();
const username = selectUsername(state);
const projectCount = userProjects.selector(state).items.length;
const projectsPerPage = 20;
const projectsPerPage = 24;
const opts = {
...Endpoints[type](state),
params: {
limit: projectsPerPage,
offset: projectCount
}
};
dispatch(userProjects.actions.loading());
api({
uri: Uris[type](username),
params: {limit: projectsPerPage, offset: projectCount}
}, (err, body, res) => {
api(opts, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return dispatch(userProjects.actions.error(error));
const moreToLoad = body.length === projectsPerPage;

View file

@ -0,0 +1,158 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage, injectIntl, intlShape} from 'react-intl';
import {selectStudioTitle, selectStudioDescription, selectStudioImage} from '../../../redux/studio';
import Modal from '../../../components/modal/base/modal.jsx';
import ModalTitle from '../../../components/modal/base/modal-title.jsx';
import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx';
import StudioReportTile from './studio-report-tile.jsx';
import './studio-report-modal.scss';
import {
Fields,
actions,
selectors
} from '../../../redux/studio-report';
const StudioReportModal = ({
description,
error,
field,
image,
intl,
isSubmitting,
previouslyReported,
title,
handleSetField,
handleClose,
handleSubmit
}) => {
const handleChange = event => handleSetField(event.target.value);
return (
<div>
{error && (
<div>
<div>There was an error. Try again later?</div>
<div><code><pre>{error}</pre></code></div>
</div>
)}
{previouslyReported ? (
<Modal
isOpen
className="studio-report-modal"
onRequestClose={handleClose}
useStandardSizes
>
<ModalTitle
className="studio-report-title"
/>
<div
className="studio-report-thanks-content"
>
<img
src="/svgs/studio/report-thanks.svg"
className="studio-report-thanks-image"
/>
<ModalInnerContent
className="studio-report-inner"
>
<h2><FormattedMessage id="studio.reportThanksForLettingUsKnow" /></h2>
<p><FormattedMessage id="studio.reportYourFeedback" /></p>
</ModalInnerContent>
</div>
</Modal>
) : (
<Modal
isOpen
className="studio-report-modal"
onRequestClose={handleClose}
>
<ModalTitle
className="studio-report-title"
title={intl.formatMessage({id: 'studio.reportThisStudio'})}
/>
<ModalInnerContent
className="studio-report-inner"
>
<h3><FormattedMessage id="studio.reportThisStudio" /></h3>
<p><FormattedMessage id="studio.reportPleaseExplain" /></p>
<div className="studio-report-tile-container">
<StudioReportTile
handleChange={handleChange}
heading={intl.formatMessage({id: 'studio.title'})}
selected={field === Fields.TITLE}
title={title}
value={Fields.TITLE}
/>
<StudioReportTile
handleChange={handleChange}
heading={intl.formatMessage({id: 'studio.description'})}
selected={field === Fields.DESCRIPTION}
description={description}
value={Fields.DESCRIPTION}
/>
<StudioReportTile
handleChange={handleChange}
heading={intl.formatMessage({id: 'studio.thumbnail'})}
selected={field === Fields.THUMBNAIL}
image={image}
value={Fields.THUMBNAIL}
/>
</div>
<p><FormattedMessage id="studio.reportAreThereComments" /></p>
<div
className="studio-report-button-row"
>
<button
className="button"
disabled={field === null || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting && <FormattedMessage id="report.sending" />}
{!isSubmitting && <FormattedMessage id="report.send" />}
</button>
</div>
</ModalInnerContent>
</Modal>
)}
</div>
);
};
StudioReportModal.propTypes = {
description: PropTypes.string,
error: PropTypes.string,
field: PropTypes.string,
intl: intlShape,
isSubmitting: PropTypes.bool,
previouslyReported: PropTypes.bool,
handleClose: PropTypes.func,
handleSetField: PropTypes.func,
handleSubmit: PropTypes.func,
image: PropTypes.string,
title: PropTypes.string
};
export default connect(
state => ({
description: selectStudioDescription(state),
error: selectors.selectStudioReportError(state),
field: selectors.selectStudioReportField(state),
image: selectStudioImage(state),
isOpen: selectors.selectStudioReportOpen(state),
isSubmitting: selectors.selectStudioReportSubmitting(state),
previouslyReported: selectors.selectStudioReportSubmitted(state),
title: selectStudioTitle(state)
}),
{
handleOpen: actions.openStudioReport,
handleClose: actions.closeStudioReport,
handleSetField: actions.setStudioReportField,
handleSubmit: actions.submitStudioReport
}
)(injectIntl(StudioReportModal));

View file

@ -0,0 +1,104 @@
@import "../../../colors";
@import "../../../frameless";
.studio-report-modal {
.studio-report-title {
box-shadow: inset 0 -1px 0 0 $ui-aqua-dark;
background: $ui-aqua;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
padding-top: .75rem;
width: 100%;
height: 3rem;
cursor: pointer;
}
.studio-report-inner {
padding: 2rem;
}
.studio-report-tile-container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@media #{$medium} { /* Keep 3 columns to narrower width since it is in a modal */
& { grid-template-columns: 1fr 1fr; }
}
@media #{$small} {
& { grid-template-columns: 1fr; }
}
column-gap: 14px;
row-gap: 14px;
}
.button {
margin: 0px;
}
.button:disabled {
background-color: $active-dark-gray;
}
.studio-report-tile {
background: rgba(0, 0, 0, 0.05);
width: 230px;
height: 156px;
border-radius: 0.5rem 0.5rem 0 0;
}
.studio-report-tile-header {
background: rgba(0, 0, 0, 0.05);
border-radius: 0.5rem 0.5rem 0 0;
padding: 12px;
}
.studio-report-tile-header-text {
padding-left: 0.5rem;
}
.studio-report-tile-text-container {
overflow: auto;
overflow-wrap: break-word;
height: 90px;
padding: 0.5rem;
}
.studio-report-title-text {
text-align: center;
}
.studio-report-tile-image-container {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
}
.studio-report-tile-image {
border-radius: 0.5rem;
max-width: 130px;
max-height: 100px;
}
.studio-report-selected {
background: hsla(215, 100, 65, .15);
}
.studio-report-header-selected {
background: $ui-blue-25percent;
}
.studio-report-button-row {
display: flex;
justify-content: flex-end;
}
.studio-report-thanks-content {
display: flex;
}
.studio-report-thanks-image {
margin-top: 2rem;
margin-bottom: 2rem
}
}

View file

@ -0,0 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
const StudioReportTile = props =>
(
<label>
<div
className={classNames(
'studio-report-tile',
{'studio-report-selected': props.selected}
)}
>
<div
className={classNames(
'studio-report-tile-header',
{'studio-report-header-selected': props.selected}
)}
>
<input
checked={props.selected}
type="radio"
name="studio-report"
value={props.value}
onChange={props.handleChange}
/>
<span className="studio-report-tile-header-text">
{props.heading}
</span>
</div>
{props.title &&
<div className="studio-report-tile-text-container">
<div className="studio-report-title-text">
<h3>{props.title}</h3>
</div>
</div>
}
{props.description &&
<div className="studio-report-tile-text-container">
<div>
{props.description}
</div>
</div>
}
{props.image &&
<div className="studio-report-tile-image-container">
<img
src={props.image}
className="studio-report-tile-image"
/>
</div>
}
</div>
</label>
);
StudioReportTile.propTypes = {
description: PropTypes.string,
heading: PropTypes.string,
title: PropTypes.string,
handleChange: PropTypes.func,
image: PropTypes.string,
selected: PropTypes.bool,
value: PropTypes.string
};
export default StudioReportTile;

View file

@ -5,6 +5,7 @@ import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {selectClassroomId} from '../../../redux/studio';
import {addProject, removeProject} from '../lib/studio-project-actions';
import {userProjects} from '../lib/redux-modules';
import {Filters, loadUserProjects, clearUserProjects} from '../lib/user-projects-actions';
@ -16,10 +17,13 @@ import SubNavigation from '../../../components/subnavigation/subnavigation.jsx';
import UserProjectsTile from './user-projects-tile.jsx';
import './user-projects-modal.scss';
import {selectIsEducator} from '../../../redux/session';
import AlertProvider from '../../../components/alert/alert-provider.jsx';
import Alert from '../../../components/alert/alert.jsx';
const UserProjectsModal = ({
items, error, loading, moreToLoad, onLoadMore, onClear,
onAdd, onRemove, onRequestClose
items, error, loading, moreToLoad, showStudentsFilter,
onLoadMore, onClear, onAdd, onRemove, onRequestClose
}) => {
const [filter, setFilter] = useState(Filters.SHARED);
@ -42,55 +46,70 @@ const UserProjectsModal = ({
align="left"
className="user-projects-modal-nav"
>
<li
<button
className={classNames({active: filter === Filters.SHARED})}
onClick={() => setFilter(Filters.SHARED)}
>
<FormattedMessage id="studio.sharedFilter" />
</li>
<li
</button>
<button
className={classNames({active: filter === Filters.FAVORITED})}
onClick={() => setFilter(Filters.FAVORITED)}
>
<FormattedMessage id="studio.favoritedFilter" />
</li>
<li
</button>
<button
className={classNames({active: filter === Filters.RECENT})}
onClick={() => setFilter(Filters.RECENT)}
>
<FormattedMessage id="studio.recentFilter" />
</li>
</button>
{showStudentsFilter &&
<button
className={classNames({active: filter === Filters.STUDENTS})}
onClick={() => setFilter(Filters.STUDENTS)}
>
<FormattedMessage id="studio.studentsFilter" />
</button>
}
</SubNavigation>
<ModalInnerContent className="user-projects-modal-content">
{error && <div>Error loading {filter}: {error}</div>}
<div className="user-projects-modal-grid">
{items.map(project => (
<UserProjectsTile
key={project.id}
id={project.id}
title={project.title}
image={project.image}
inStudio={project.inStudio}
onAdd={onAdd}
onRemove={onRemove}
/>
))}
<div className="studio-projects-load-more">
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={() => onLoadMore(filter)}>
<FormattedMessage id="general.loadMore" />
</button> :
<small>No more to load</small>
)}
<AlertProvider>
{error && <div>Error loading {filter}: {error}</div>}
<Alert className="studio-alert" />
<div className="user-projects-modal-grid">
{items.map(project => (
<UserProjectsTile
key={project.id}
id={project.id}
title={project.title}
image={project.image}
inStudio={project.inStudio}
onAdd={onAdd}
onRemove={onRemove}
/>
))}
</div>
</div>
{moreToLoad &&
<div className="studio-projects-load-more">
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={() => onLoadMore(filter)}
>
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</AlertProvider>
</ModalInnerContent>
</Modal>
);
};
UserProjectsModal.propTypes = {
showStudentsFilter: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.id,
image: PropTypes.string,
@ -108,7 +127,8 @@ UserProjectsModal.propTypes = {
};
const mapStateToProps = state => ({
...userProjects.selector(state)
...userProjects.selector(state),
showStudentsFilter: selectIsEducator(state) && selectClassroomId(state)
});
const mapDispatchToProps = ({

View file

@ -13,7 +13,7 @@
}
.user-projects-modal-nav {
padding: 6px 12px;
li {
button {
cursor: pointer;
background: rgba(0, 0, 0, 0.15);
&.active { background: $ui-blue; }
@ -22,14 +22,29 @@
.user-projects-modal-content {
padding: 0 30px 30px;
background: #E9F1FC;
max-height: 80vh;
max-height: calc(100vh - 200px);
overflow-y: auto;
overscroll-behavior: contain;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
@media #{$intermediate-and-smaller} {
& { max-height: calc(100vh - 105px); }
}
}
.studio-projects-load-more {
display: contents;
}
}
.studio-tile-added {
border: 1px solid $ui-blue !important; // Override the tile border set in studio.scss .studio-project-tile
box-shadow: 0 0 0 4px $ui-blue-25percent;
}
.studio-project-add-remove-image {
margin: 7px;
}
.studio-tile-dynamic-remove,
.studio-tile-dynamic-add {
@ -47,7 +62,9 @@
margin: 0;
padding: 0;
line-height: 32px;
text-align: center;
align-content: center;
display: flex;
justify-content: center;
}
.studio-tile-dynamic-remove {
background: #0FBD8C;
@ -57,6 +74,7 @@
.user-projects-modal-grid {
margin-top: 12px;
margin-bottom: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
@ -69,10 +87,6 @@
column-gap: 14px;
row-gap: 14px;
.studio-projects-load-more {
grid-column: 1 / -1;
}
.studio-project-bottom {
padding: 8px 10px 8px 10px;
}

View file

@ -1,23 +1,26 @@
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import React, {useContext, useState} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import AlertContext from '../../../components/alert/alert-context.js';
const UserProjectsTile = ({id, title, image, inStudio, onAdd, onRemove}) => {
const [submitting, setSubmitting] = useState(false);
const [added, setAdded] = useState(inStudio);
const [error, setError] = useState(null);
const {errorAlert} = useContext(AlertContext);
const toggle = () => {
setSubmitting(true);
setError(null);
(added ? onRemove(id) : onAdd(id))
.then(() => {
setAdded(!added);
setSubmitting(false);
})
.catch(e => {
setError(e);
.catch(() => {
setSubmitting(false);
errorAlert({
id: added ? 'studio.alertProjectRemoveError' :
'studio.alertProjectAddError'
}, null);
});
};
return (
@ -25,11 +28,17 @@ const UserProjectsTile = ({id, title, image, inStudio, onAdd, onRemove}) => {
role="button"
tabIndex="0"
className={classNames('studio-project-tile', {
'studio-tile-added': added,
'mod-clickable': true,
'mod-mutating': submitting
})}
onClick={toggle}
onKeyDown={e => e.key === 'Enter' && toggle()}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
toggle();
e.preventDefault();
}
}}
>
<img
className="studio-project-image"
@ -38,9 +47,14 @@ const UserProjectsTile = ({id, title, image, inStudio, onAdd, onRemove}) => {
<div className="studio-project-bottom">
<div className="studio-project-title">{title}</div>
<div className={`studio-tile-dynamic-${added ? 'remove' : 'add'}`}>
{added ? '✔' : ''}
<img
className="studio-project-add-remove-image"
src={added ?
'/svgs/studio/check-icon-white.svg' :
'/svgs/studio/plus-icon-white.svg'
}
/>
</div>
{error && <div>{error}</div>}
</div>
</div>
);

View file

@ -177,7 +177,9 @@ const StudioActivity = ({items, loading, error, moreToLoad, onLoadMore}) => {
return (
<div className="studio-activity">
<h2>Activity</h2>
<div className="studio-header-container">
<h2><FormattedMessage id="studio.activityHeader" /></h2>
</div>
{loading && <div>Loading...</div>}
{error && <Debug
label="Error"

View file

@ -0,0 +1,95 @@
/* eslint-disable react/jsx-no-bind */
import React, {useState, useEffect} from 'react';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {selectIsAdmin} from '../../redux/session.js';
import {selectStudioId} from '../../redux/studio.js';
import log from '../../lib/log';
import AdminPanel from '../../components/adminpanel/adminpanel.jsx';
const adminPanelOpenKey = 'adminPanelToggled_studios';
const adminPanelOpenClass = 'mod-view-admin-panel-open';
/**
* Propagate the admin panel openness to localStorage and set a class name
* on the #view element.
* @param {boolean} value - whether the admin panel is now open.
*/
const storeAdminPanelOpen = value => {
try {
localStorage.setItem(adminPanelOpenKey, value ? 'open' : 'closed');
} catch (e) {
log.error('Could not set adminPanelToggled_studios in local storage', e);
}
try {
document.querySelector('#view').classList
.toggle(adminPanelOpenClass, value);
} catch (e) {
log.error('Could not set admin-panel-open class on #view');
}
};
/**
* Get the previous stored value of admin panel openness from localStorage.
* @returns {boolean} - whether the admin panel should be open.
*/
const getAdminPanelOpen = () => {
try {
return localStorage.getItem(adminPanelOpenKey) === 'open';
} catch (_) {
return false;
}
};
const StudioAdminPanel = ({studioId, showAdminPanel}) => {
const [adminPanelOpen, setAdminPanelOpen] = useState(getAdminPanelOpen());
useEffect(() => {
storeAdminPanelOpen(adminPanelOpen);
}, [adminPanelOpen]);
useEffect(() => {
if (!showAdminPanel) return;
const handleMessage = e => {
if (e.data === 'closePanel') setAdminPanelOpen(false);
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [showAdminPanel]);
return showAdminPanel && (
<AdminPanel
className={classNames('studio-admin-panel', {
'admin-panel-open': adminPanelOpen
})}
isOpen={adminPanelOpen}
onOpen={() => setAdminPanelOpen(true)}
>
<iframe
className="admin-iframe"
src={`/scratch2-studios/${studioId}/adminpanel/`}
/>
</AdminPanel>
);
};
const ConnnectedStudioAdminPanel = connect(
state => ({
studioId: selectStudioId(state),
showAdminPanel: selectIsAdmin(state)
})
)(StudioAdminPanel);
export {
ConnnectedStudioAdminPanel as default,
// Export the unconnected component by name for testing
StudioAdminPanel,
// Export some constants for easy testing as well
adminPanelOpenClass,
adminPanelOpenKey
};

View file

@ -2,12 +2,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import {selectStudioCommentsAllowed, selectIsFetchingInfo} from '../../redux/studio';
import {
mutateStudioCommentsAllowed, selectIsMutatingCommentsAllowed, selectCommentsAllowedMutationError
} from '../../redux/studio-mutations';
import ToggleSlider from '../../components/forms/toggle-slider.jsx';
const StudioCommentsAllowed = ({
commentsAllowedError, isFetching, isMutating, commentsAllowed, handleUpdate
}) => (
@ -16,16 +20,20 @@ const StudioCommentsAllowed = ({
<h4>Fetching...</h4>
) : (
<div>
<label>
<input
disabled={isMutating}
type="checkbox"
checked={commentsAllowed}
onChange={e => handleUpdate(e.target.checked)}
/>
<span>{commentsAllowed ? 'Comments allowed' : 'Comments not allowed'}</span>
{commentsAllowedError && <div>Error mutating commentsAllowed: {commentsAllowedError}</div>}
</label>
{commentsAllowed ? (
<FormattedMessage id="studio.comments.toggleOn" />
) : (
<FormattedMessage id="studio.comments.toggleOff" />
)}
<ToggleSlider
disabled={isMutating}
checked={commentsAllowed}
className={classNames('comments-allowed-input', {
'mod-mutating': isMutating
})}
onChange={e => handleUpdate(e.target.checked)}
/>
{commentsAllowedError && <div>Error mutating commentsAllowed: {commentsAllowedError}</div>}
</div>
)}
</div>

View file

@ -9,7 +9,7 @@ import TopLevelComment from '../preview/comment/top-level-comment.jsx';
import studioCommentActions from '../../redux/studio-comment-actions.js';
import StudioCommentsAllowed from './studio-comments-allowed.jsx';
import {selectIsAdmin} from '../../redux/session';
import {selectIsAdmin, selectHasFetchedSession} from '../../redux/session';
import {
selectShowCommentComposer,
selectCanDeleteComment,
@ -24,6 +24,7 @@ const StudioComments = ({
comments,
commentsAllowed,
isAdmin,
hasFetchedSession,
handleLoadMoreComments,
handleNewComment,
moreCommentsToLoad,
@ -42,8 +43,8 @@ const StudioComments = ({
handleLoadMoreReplies
}) => {
useEffect(() => {
if (comments.length === 0) handleLoadMoreComments();
}, [comments.length === 0]);
if (comments.length === 0 && hasFetchedSession) handleLoadMoreComments();
}, [comments.length === 0, hasFetchedSession]);
// The comments you see depend on your admin status
// so reset them if isAdmin changes.
@ -56,9 +57,11 @@ const StudioComments = ({
return (
<div>
<h2><FormattedMessage id="studio.commentsHeader" /></h2>
{canEditCommentsAllowed && <StudioCommentsAllowed />}
<div>
<div className="studio-header-container">
<h2><FormattedMessage id="studio.commentsHeader" /></h2>
{canEditCommentsAllowed && <StudioCommentsAllowed />}
</div>
<div className="studio-compose-container">
{shouldShowCommentComposer && commentsAllowed &&
<ComposeComment
postURI={postURI}
@ -106,6 +109,7 @@ StudioComments.propTypes = {
comments: PropTypes.arrayOf(PropTypes.shape({})),
commentsAllowed: PropTypes.bool,
isAdmin: PropTypes.bool,
hasFetchedSession: PropTypes.bool,
handleLoadMoreComments: PropTypes.func,
handleNewComment: PropTypes.func,
moreCommentsToLoad: PropTypes.bool,
@ -131,6 +135,7 @@ export {
export default connect(
state => ({
comments: state.comments.comments,
hasFetchedSession: selectHasFetchedSession(state),
isAdmin: selectIsAdmin(state),
moreCommentsToLoad: state.comments.moreCommentsToLoad,
replies: state.comments.replies,

View file

@ -6,15 +6,42 @@ import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {acceptInvitation} from './lib/studio-member-actions';
import {selectShowCuratorInvite} from '../../redux/studio-permissions';
const StudioCuratorInvite = ({onSubmit}) => {
const StudioCuratorInvite = ({showCuratorInvite, onSubmit}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [accepted, setAccepted] = useState(false);
const [error, setError] = useState(false);
if (!showCuratorInvite) return null;
if (error) {
return (
<div className="studio-invitation studio-info-box studio-info-box-error">
<div className="studio-invitation-msg">
<FormattedMessage id="studio.curatorInvitationError" />
</div>
</div>
);
}
if (accepted) {
return (
<div className="studio-invitation studio-info-box studio-info-box-success">
<div className="studio-invitation-msg">
<FormattedMessage id="studio.curatorInvitationAccepted" />
</div>
</div>
);
}
return (
<div>
<div className="studio-invitation studio-info-box">
<div className="studio-invitation-msg">
<FormattedMessage id="studio.curatorInvitation" />
</div>
<button
className={classNames('button', {
className={classNames('studio-invitation-button button', {
'mod-mutating': submitting
})}
disabled={submitting}
@ -22,22 +49,28 @@ const StudioCuratorInvite = ({onSubmit}) => {
setSubmitting(true);
setError(null);
onSubmit()
.then(() => {
setSubmitting(false);
setAccepted(true);
})
.catch(e => {
setError(e);
setSubmitting(false);
});
}}
><FormattedMessage id="studio.curatorAcceptInvite" /></button>
{error && <div>{error}</div>}
</div>
);
};
StudioCuratorInvite.propTypes = {
showCuratorInvite: PropTypes.func,
onSubmit: PropTypes.func
};
const mapStateToProps = () => ({});
const mapStateToProps = state => ({
showCuratorInvite: selectShowCuratorInvite(state)
});
const mapDispatchToProps = ({
onSubmit: acceptInvitation

View file

@ -3,31 +3,70 @@ import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, intlShape, injectIntl} from 'react-intl';
import {inviteCurator} from './lib/studio-member-actions';
import FlexRow from '../../components/flex-row/flex-row.jsx';
import {useAlertContext} from '../../components/alert/alert-context';
import {Errors, inviteCurator} from './lib/studio-member-actions';
import ValidationMessage from '../../components/forms/validation-message.jsx';
const StudioCuratorInviter = ({onSubmit}) => {
const errorToMessageId = error => {
switch (error) {
case Errors.NETWORK: return 'studio.curatorErrors.generic';
case Errors.SERVER: return 'studio.curatorErrors.generic';
case Errors.PERMISSION: return 'studio.curatorErrors.generic';
case Errors.DUPLICATE: return 'studio.curatorErrors.alreadyCurator';
case Errors.UNKNOWN_USERNAME: return 'studio.curatorErrors.unknownUsername';
case Errors.RATE_LIMIT: return 'studio.curatorErrors.tooFast';
default: return 'studio.curatorErrors.generic';
}
};
const StudioCuratorInviter = ({intl, onSubmit}) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const {successAlert} = useAlertContext();
const submit = () => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => {
successAlert({
id: 'studio.alertCuratorInvited',
values: {name: value}
});
setValue('');
})
.catch(e => {
if (e === Errors.DUPLICATE) {
successAlert({
id: 'studio.alertCuratorAlreadyInvited',
values: {name: value}
});
setValue('');
} else {
setError(e);
}
})
.then(() => setSubmitting(false));
};
return (
<div className="studio-adder-section">
<h3><FormattedMessage id="studio.inviteCuratorsHeader" /></h3>
<FlexRow>
<div className="studio-adder-row">
{error && <div className="studio-adder-error">
<ValidationMessage
mode="error"
className="validation-left"
message={<FormattedMessage id={errorToMessageId(error)} />}
/>
</div>}
<input
className={classNames({'mod-form-error': error})}
disabled={submitting}
type="text"
placeholder="<username>"
placeholder={intl.formatMessage({id: 'studio.inviteCuratorPlaceholder'})}
value={value}
onKeyDown={e => e.key === 'Enter' && submit()}
onChange={e => setValue(e.target.value)}
@ -36,17 +75,17 @@ const StudioCuratorInviter = ({onSubmit}) => {
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
disabled={submitting || value === ''}
onClick={submit}
><FormattedMessage id="studio.inviteCurator" /></button>
{error && <div>{error}</div>}
</FlexRow>
</div>
</div>
);
};
StudioCuratorInviter.propTypes = {
onSubmit: PropTypes.func
onSubmit: PropTypes.func,
intl: intlShape
};
const mapStateToProps = () => ({});
@ -55,4 +94,4 @@ const mapDispatchToProps = ({
onSubmit: inviteCurator
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioCuratorInviter);
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(StudioCuratorInviter));

View file

@ -8,70 +8,76 @@ import {curators} from './lib/redux-modules';
import Debug from './debug.jsx';
import {CuratorTile} from './studio-member-tile.jsx';
import CuratorInviter from './studio-curator-inviter.jsx';
import CuratorInvite from './studio-curator-invite.jsx';
import {loadCurators} from './lib/studio-member-actions';
import {selectCanInviteCurators, selectShowCuratorInvite} from '../../redux/studio-permissions';
import {selectCanInviteCurators} from '../../redux/studio-permissions';
import AlertProvider from '../../components/alert/alert-provider.jsx';
import Alert from '../../components/alert/alert.jsx';
const StudioCurators = ({
canInviteCurators, showCuratorInvite, items, error, loading, moreToLoad, onLoadMore
canInviteCurators, items, error, loading, moreToLoad, onLoadMore
}) => {
useEffect(() => {
if (items.length === 0) onLoadMore();
}, []);
return (<div className="studio-members">
<h2><FormattedMessage id="studio.curatorsHeader" /></h2>
{canInviteCurators && <CuratorInviter />}
{showCuratorInvite && <CuratorInvite />}
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.length === 0 && !loading ? (
<div className="studio-empty">
<img
width="179"
height="111"
className="studio-empty-img"
src="/images/studios/curators-empty.png"
/>
{canInviteCurators ? (
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.curatorsEmptyCanAdd1" /></div>
<div><FormattedMessage id="studio.curatorsEmptyCanAdd2" /></div>
return (
<AlertProvider>
<div className="studio-members">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.curatorsHeader" /></h2>
</div>
{canInviteCurators && <CuratorInviter />}
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.length === 0 && !loading ? (
<div className="studio-empty">
<img
width="179"
height="111"
className="studio-empty-img"
src="/images/studios/curators-empty.png"
/>
{canInviteCurators ? (
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.curatorsEmptyCanAdd1" /></div>
<div><FormattedMessage id="studio.curatorsEmptyCanAdd2" /></div>
</div>
) : (
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.curatorsEmpty1" /></div>
</div>
)}
</div>
) : (
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.curatorsEmpty1" /></div>
</div>
<React.Fragment>
{items.map(item =>
(<CuratorTile
key={item.username}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
{moreToLoad &&
<div className="studio-members-load-more">
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={onLoadMore}
>
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</React.Fragment>
)}
</div>
) : (
<React.Fragment>
{items.map(item =>
(<CuratorTile
key={item.username}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
{moreToLoad &&
<div className="studio-members-load-more">
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={onLoadMore}
>
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</React.Fragment>
)}
</div>
</div>);
</div>
</AlertProvider>);
};
StudioCurators.propTypes = {
@ -85,7 +91,6 @@ StudioCurators.propTypes = {
})
})),
canInviteCurators: PropTypes.bool,
showCuratorInvite: PropTypes.bool,
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
@ -95,8 +100,7 @@ StudioCurators.propTypes = {
export default connect(
state => ({
...curators.selector(state),
canInviteCurators: selectCanInviteCurators(state),
showCuratorInvite: selectShowCuratorInvite(state)
canInviteCurators: selectCanInviteCurators(state)
}),
{
onLoadMore: loadCurators

View file

@ -1,34 +1,73 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {selectStudioDescription, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {selectCanEditInfo, selectShowEditMuteError} from '../../redux/studio-permissions';
import {
mutateStudioDescription, selectIsMutatingDescription, selectDescriptionMutationError
Errors, mutateStudioDescription, selectIsMutatingDescription, selectDescriptionMutationError
} from '../../redux/studio-mutations';
import classNames from 'classnames';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import decorateText from '../../lib/decorate-text.jsx';
import StudioMuteEditMessage from './studio-mute-edit-message.jsx';
const errorToMessageId = error => {
switch (error) {
case Errors.INAPPROPRIATE: return 'studio.updateErrors.inappropriate';
case Errors.TEXT_TOO_LONG: return 'studio.updateErrors.textTooLong';
case Errors.REQUIRED_FIELD: return 'studio.updateErrors.requiredField';
default: return 'studio.updateErrors.generic';
}
};
const StudioDescription = ({
descriptionError, isFetching, isMutating, description, canEditInfo, handleUpdate
descriptionError, isFetching, isMutating, isMutedEditor, description, canEditInfo, handleUpdate
}) => {
const [showMuteMessage, setShowMuteMessage] = useState(false);
const fieldClassName = classNames('studio-description', {
'mod-fetching': isFetching,
'mod-mutating': isMutating
'mod-mutating': isMutating,
'mod-form-error': !!descriptionError,
'muted-editor': showMuteMessage
});
return (
<React.Fragment>
<textarea
rows="20"
className={fieldClassName}
disabled={isMutating || !canEditInfo || isFetching}
defaultValue={description}
onBlur={e => e.target.value !== description &&
<div
className="studio-info-section"
onMouseEnter={() => isMutedEditor && setShowMuteMessage(true)}
onMouseLeave={() => isMutedEditor && setShowMuteMessage(false)}
>
{canEditInfo || isMutedEditor ? (
<React.Fragment>
<textarea
rows="20"
className={fieldClassName}
disabled={isMutating || isFetching || isMutedEditor}
defaultValue={description}
onBlur={e => e.target.value !== description &&
handleUpdate(e.target.value)}
/>
{descriptionError && <div>Error mutating description: {descriptionError}</div>}
</React.Fragment>
/>
{descriptionError && <ValidationMessage
mode="error"
message={<FormattedMessage id={errorToMessageId(descriptionError)} />}
/>}
{showMuteMessage && <StudioMuteEditMessage />}
</React.Fragment>
) : (
<div className={fieldClassName}>
{decorateText(description, {
usernames: true,
hashtags: false,
scratchLinks: true
})}
</div>
)}
</div>
);
};
@ -37,6 +76,7 @@ StudioDescription.propTypes = {
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
isMutedEditor: PropTypes.bool,
description: PropTypes.string,
handleUpdate: PropTypes.func
};
@ -47,6 +87,7 @@ export default connect(
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingDescription(state),
isMutedEditor: selectShowEditMuteError(state),
descriptionError: selectDescriptionMutationError(state)
}),
{

View file

@ -19,7 +19,7 @@ const StudioFollow = ({
handleFollow
}) => {
if (!canFollow) return null;
const fieldClassName = classNames('button', {
const fieldClassName = classNames('button', 'studio-follow-button', {
'mod-mutating': isMutating
});
return (

View file

@ -1,44 +1,92 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {selectStudioImage, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {selectCanEditInfo, selectShowEditMuteError} from '../../redux/studio-permissions';
import {
mutateStudioImage, selectIsMutatingImage, selectImageMutationError
Errors, mutateStudioImage, selectIsMutatingImage, selectImageMutationError
} from '../../redux/studio-mutations';
import classNames from 'classnames';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import StudioMuteEditMessage from './studio-mute-edit-message.jsx';
import editIcon from './icons/edit-icon.svg';
const errorToMessageId = error => {
switch (error) {
case Errors.THUMBNAIL_INVALID: return 'studio.updateErrors.thumbnailInvalid';
case Errors.THUMBNAIL_TOO_LARGE: return 'studio.updateErrors.thumbnailTooLarge';
default: return 'studio.updateErrors.generic';
}
};
const blankImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const StudioImage = ({
imageError, isFetching, isMutating, image, canEditInfo, handleUpdate
imageError, isFetching, isMutating, isMutedEditor, image, canEditInfo, handleUpdate
}) => {
const fieldClassName = classNames('studio-image', {
const [uploadPreview, setUploadPreview] = React.useState(null);
const fieldClassName = classNames('studio-info-section', {
'mod-fetching': isFetching,
'mod-mutating': isMutating
'mod-mutating': isMutating,
'muted': isMutedEditor
});
const src = isMutating ? blankImage : (image || blankImage);
let src = image || blankImage;
if (uploadPreview && !imageError) src = uploadPreview;
const labelFieldClassName = classNames({
'mod-mutating': isMutating,
'mod-clickable': !isMutating
});
const [showMuteMessage, setShowMuteMessage] = useState(false);
return (
<div className={fieldClassName}>
<div
className={fieldClassName}
onMouseEnter={() => isMutedEditor && setShowMuteMessage(true)}
onMouseLeave={() => isMutedEditor && setShowMuteMessage(false)}
>
<img
style={{width: '300px', height: '225px', objectFit: 'cover'}}
className="studio-image"
src={src}
/>
{canEditInfo && !isFetching &&
{(isMutedEditor || canEditInfo) && !isFetching &&
<React.Fragment>
<label
htmlFor="studio-thumb-edit-input"
className={labelFieldClassName}
>
<div className="studio-thumb-edit-button">
<img
className="studio-thumb-edit-img"
src={editIcon}
/>
<FormattedMessage id="studio.editThumbnail" />
</div>
</label>
<input
disabled={isMutating}
id="studio-thumb-edit-input"
className="hidden"
disabled={isMutating || !canEditInfo}
type="file"
accept="image/*"
onChange={e => {
handleUpdate(e.target);
handleUpdate(e.target)
.then(dataUrl => setUploadPreview(dataUrl));
e.target.value = '';
}}
/>
{imageError && <div>Error mutating image: {imageError}</div>}
{imageError && <ValidationMessage
mode="error"
message={<FormattedMessage id={errorToMessageId(imageError)} />}
/>}
</React.Fragment>
}
{showMuteMessage && <StudioMuteEditMessage />}
</div>
);
};
@ -48,6 +96,7 @@ StudioImage.propTypes = {
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
isMutedEditor: PropTypes.bool,
image: PropTypes.string,
handleUpdate: PropTypes.func
};
@ -58,6 +107,7 @@ export default connect(
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingImage(state),
isMutedEditor: selectShowEditMuteError(state),
imageError: selectImageMutationError(state)
}),
{

View file

@ -1,14 +1,16 @@
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import StudioDescription from './studio-description.jsx';
import StudioFollow from './studio-follow.jsx';
import StudioTitle from './studio-title.jsx';
import StudioImage from './studio-image.jsx';
import StudioReport from './studio-report.jsx';
import StudioStats from './studio-stats.jsx';
import StudioTitle from './studio-title.jsx';
import {selectIsLoggedIn} from '../../redux/session';
import {getInfo, getRoles} from '../../redux/studio';
import StudioReport from './studio-report.jsx';
const StudioInfo = ({
isLoggedIn, onLoadInfo, onLoadRoles
@ -27,7 +29,14 @@ const StudioInfo = ({
<StudioFollow />
<StudioImage />
<StudioDescription />
<StudioReport />
<div className="studio-info-footer">
<div className="studio-info-footer-stats">
<StudioStats />
</div>
<div className="studio-info-footer-report">
<StudioReport />
</div>
</div>
</React.Fragment>
);
};

View file

@ -8,6 +8,8 @@ import {managers} from './lib/redux-modules';
import {loadManagers} from './lib/studio-member-actions';
import Debug from './debug.jsx';
import {ManagerTile} from './studio-member-tile.jsx';
import AlertProvider from '../../components/alert/alert-provider.jsx';
import Alert from '../../components/alert/alert.jsx';
const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
@ -16,22 +18,26 @@ const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
}, []);
return (
<div className="studio-members">
<h2><FormattedMessage id="studio.managersHeader" /></h2>
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.map(item =>
(<ManagerTile
key={item.username}
id={item.id}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
{moreToLoad &&
<AlertProvider>
<div className="studio-members">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.managersHeader" /></h2>
</div>
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.map(item =>
(<ManagerTile
key={item.username}
id={item.id}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
{moreToLoad &&
<div className="studio-members-load-more">
<button
className={classNames('button', {
@ -42,9 +48,10 @@ const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
}
</div>
</div>
</div>
</AlertProvider>
);
};

View file

@ -6,20 +6,25 @@ import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {
selectCanRemoveCurators, selectCanRemoveManager, selectCanPromoteCurators
selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators
} from '../../redux/studio-permissions';
import {
promoteCurator,
removeCurator,
removeManager
} from './lib/studio-member-actions';
import {useAlertContext} from '../../components/alert/alert-context';
import OverflowMenu from '../../components/overflow-menu/overflow-menu.jsx';
import removeIcon from './icons/remove-icon.svg';
import promoteIcon from './icons/curator-icon.svg';
const StudioMemberTile = ({
canRemove, canPromote, onRemove, onPromote, isCreator, // mapState props
username, image // own props
}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const {errorAlert, successAlert} = useAlertContext();
const userUrl = `/users/${username}`;
return (
<div className="studio-member-tile">
@ -36,39 +41,59 @@ const StudioMemberTile = ({
>{username}</a>
{isCreator && <div className="studio-member-role"><FormattedMessage id="studio.creatorRole" /></div>}
</div>
{canRemove &&
<button
className={classNames('studio-member-remove', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onRemove(username).catch(e => {
setError(e);
setSubmitting(false);
});
}}
></button>
{(canRemove || canPromote) &&
<OverflowMenu>
{canPromote && <li>
<button
className={classNames({
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
onPromote(username)
.then(() => {
successAlert({
id: 'studio.alertManagerPromote',
values: {name: username}
});
})
.catch(() => {
errorAlert({
id: 'studio.alertManagerPromoteError',
values: {name: username}
});
setSubmitting(false);
});
}}
>
<img src={promoteIcon} />
<FormattedMessage id="studio.promote" />
</button>
</li>}
{canRemove && <li>
<button
className={classNames({
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
onRemove(username).catch(() => {
errorAlert({
id: 'studio.alertMemberRemoveError',
values: {name: username}
}, null);
setSubmitting(false);
});
}}
>
<img src={removeIcon} />
<FormattedMessage id="studio.remove" />
</button>
</li>}
</OverflowMenu>
}
{canPromote &&
<button
className={classNames('studio-member-promote', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onPromote(username).catch(e => {
setError(e);
setSubmitting(false);
});
}}
>🆙</button>
}
{error && <div>{error}</div>}
</div>
);
};
@ -95,8 +120,8 @@ const ManagerTile = connect(
)(StudioMemberTile);
const CuratorTile = connect(
state => ({
canRemove: selectCanRemoveCurators(state),
(state, ownProps) => ({
canRemove: selectCanRemoveCurator(state, ownProps.username),
canPromote: selectCanPromoteCurators(state)
}),
{

View file

@ -0,0 +1,43 @@
import React from 'react';
import Helmet from 'react-helmet';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectStudioDescription, selectStudioId, selectStudioTitle} from '../../redux/studio';
const StudioMeta = ({id, description, title}) => (
<Helmet>
<title>{`${title} - Scratch Studio`}</title>
<meta
content={`${title}, a studio on Scratch`}
name="description"
/>
<meta
content={`Scratch - ${title}`}
property="og:title"
/>
<meta
content={description.split(' ').slice(0, 50)
.join(' ')}
property="og:description"
/>
<link
href={`https://scratch.mit.edu/studios/${id}`}
rel="canonical"
/>
</Helmet>
);
StudioMeta.propTypes = {
description: PropTypes.string,
id: PropTypes.string,
title: PropTypes.string
};
export default connect(
state => ({
description: selectStudioDescription(state),
id: selectStudioId(state),
title: selectStudioTitle(state)
})
)(StudioMeta);

View file

@ -0,0 +1,34 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import {selectMuteStatus} from '../../redux/session';
import {formatRelativeTime} from '../../lib/format-time.js';
const StudioMuteEditMessage = ({
muteExpiresAtMs
}) => (
<ValidationMessage
mode="info"
message={<FormattedMessage
id="studios.mutedEdit"
values={{
inDuration: formatRelativeTime(muteExpiresAtMs, window._locale)
}}
/>}
/>
);
StudioMuteEditMessage.propTypes = {
muteExpiresAtMs: PropTypes.number
};
export default connect(
state => ({
muteExpiresAtMs: (selectMuteStatus(state).muteExpiresAt * 1000 || 0)
})
)(StudioMuteEditMessage);

View file

@ -2,12 +2,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import {selectStudioOpenToAll, selectIsFetchingInfo} from '../../redux/studio';
import {
mutateStudioOpenToAll, selectIsMutatingOpenToAll, selectOpenToAllMutationError
} from '../../redux/studio-mutations';
import ToggleSlider from '../../components/forms/toggle-slider.jsx';
const StudioOpenToAll = ({
openToAllError, isFetching, isMutating, openToAll, handleUpdate
}) => (
@ -16,16 +20,16 @@ const StudioOpenToAll = ({
<h4>Fetching...</h4>
) : (
<div>
<label>
<input
disabled={isMutating}
type="checkbox"
checked={openToAll}
onChange={e => handleUpdate(e.target.checked)}
/>
<span>{openToAll ? 'Open to all' : 'Not open to all'}</span>
{openToAllError && <div>Error mutating openToAll: {openToAllError}</div>}
</label>
<FormattedMessage id="studio.openToAll" />
<ToggleSlider
disabled={isMutating}
checked={openToAll}
className={classNames('open-to-all-input', {
'mod-mutating': isMutating
})}
onChange={e => handleUpdate(e.target.checked)}
/>
{openToAllError && <div>Error mutating openToAll: {openToAllError}</div>}
</div>
)}
</div>

View file

@ -3,33 +3,70 @@ import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, intlShape, injectIntl} from 'react-intl';
import {addProject} from './lib/studio-project-actions';
import {Errors, addProject} from './lib/studio-project-actions';
import UserProjectsModal from './modals/user-projects-modal.jsx';
import FlexRow from '../../components/flex-row/flex-row.jsx';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import {useAlertContext} from '../../components/alert/alert-context';
const StudioProjectAdder = ({onSubmit}) => {
const errorToMessageId = error => {
switch (error) {
case Errors.NETWORK: return 'studio.projectErrors.generic';
case Errors.SERVER: return 'studio.projectErrors.generic';
case Errors.PERMISSION: return 'studio.projectErrors.permission';
case Errors.DUPLICATE: return 'studio.projectErrors.duplicate';
case Errors.RATE_LIMIT: return 'studio.projectErrors.tooFast';
case Errors.UNKNOWN_PROJECT: return 'studio.projectErrors.checkUrl';
default: return 'studio.projectErrors.generic';
}
};
const StudioProjectAdder = ({intl, onSubmit}) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const {successAlert} = useAlertContext();
const submit = () => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => {
successAlert({
id: 'studio.alertProjectAdded',
values: {title: value}
});
setValue('');
})
.catch(e => {
// Duplicate project will show success alert
if (e === Errors.DUPLICATE) {
successAlert({id: 'studio.alertProjectAlreadyAdded'});
setValue('');
} else {
// Other errors are displayed by this component
setError(e);
}
})
.then(() => setSubmitting(false));
};
return (
<div className="studio-adder-section">
<h3><FormattedMessage id="studio.addProjectsHeader" /></h3>
<FlexRow>
<div className="studio-adder-row">
{error && <div className="studio-adder-error">
<ValidationMessage
mode="error"
className="validation-left"
message={<FormattedMessage id={errorToMessageId(error)} />}
/>
</div>}
<input
className={classNames({'mod-form-error': error})}
disabled={submitting}
type="text"
placeholder="<project id>"
placeholder={intl.formatMessage({id: 'studio.addProjectPlaceholder'})}
value={value}
onKeyDown={e => e.key === 'Enter' && submit()}
onChange={e => setValue(e.target.value)}
@ -38,10 +75,9 @@ const StudioProjectAdder = ({onSubmit}) => {
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
disabled={submitting || value === ''}
onClick={submit}
><FormattedMessage id="studio.addProject" /></button>
{error && <div>{error}</div>}
<div className="studio-adder-vertical-divider" />
<button
className="button"
@ -50,13 +86,14 @@ const StudioProjectAdder = ({onSubmit}) => {
<FormattedMessage id="studio.browseProjects" />
</button>
{modalOpen && <UserProjectsModal onRequestClose={() => setModalOpen(false)} />}
</FlexRow>
</div>
</div>
);
};
StudioProjectAdder.propTypes = {
onSubmit: PropTypes.func
onSubmit: PropTypes.func,
intl: intlShape
};
const mapStateToProps = () => ({});
@ -65,4 +102,4 @@ const mapDispatchToProps = ({
onSubmit: addProject
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioProjectAdder);
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(StudioProjectAdder));

View file

@ -1,20 +1,25 @@
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import React, {useContext, useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import AlertContext from '../../components/alert/alert-context.js';
import {selectCanRemoveProject} from '../../redux/studio-permissions';
import {removeProject} from './lib/studio-project-actions';
import OverflowMenu from '../../components/overflow-menu/overflow-menu.jsx';
import removeIcon from './icons/remove-icon.svg';
const StudioProjectTile = ({
canRemove, onRemove, // mapState props
id, title, image, avatar, username // own props
}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const projectUrl = `/projects/${id}`;
const userUrl = `/users/${username}`;
const {errorAlert} = useContext(AlertContext);
return (
<div className="studio-project-tile">
<a href={projectUrl}>
@ -41,23 +46,27 @@ const StudioProjectTile = ({
>{username}</a>
</div>
{canRemove &&
<button
className={classNames('studio-project-remove', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onRemove(id)
.catch(e => {
setError(e);
setSubmitting(false);
});
}}
></button>
<OverflowMenu>
<li>
<button
className={classNames({
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
onRemove(id)
.catch(() => {
setSubmitting(false);
errorAlert({id: 'studio.alertProjectRemoveError'}, null);
});
}}
>
<img src={removeIcon} />
<FormattedMessage id="studio.remove" />
</button></li>
</OverflowMenu>
}
{error && <div>{error}</div>}
</div>
</div>
);

View file

@ -3,77 +3,102 @@ import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import StudioOpenToAll from './studio-open-to-all.jsx';
import {FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import {projects} from './lib/redux-modules';
import {selectCanAddProjects, selectCanEditOpenToAll} from '../../redux/studio-permissions';
import {selectCanAddProjects, selectCanEditOpenToAll, selectShowProjectMuteError} from '../../redux/studio-permissions';
import Debug from './debug.jsx';
import StudioProjectAdder from './studio-project-adder.jsx';
import StudioProjectTile from './studio-project-tile.jsx';
import {loadProjects} from './lib/studio-project-actions.js';
import classNames from 'classnames';
import CommentingStatus from '../../components/commenting-status/commenting-status.jsx';
import {selectIsMuted, selectMuteStatus} from '../../redux/session.js';
import {formatRelativeTime} from '../../lib/format-time.js';
import AlertProvider from '../../components/alert/alert-provider.jsx';
import Alert from '../../components/alert/alert.jsx';
const StudioProjects = ({
canAddProjects, canEditOpenToAll, items, error, loading, moreToLoad, onLoadMore
canAddProjects, canEditOpenToAll, items, isMuted, error,
loading, moreToLoad, onLoadMore, muteExpiresAtMs, showMuteError
}) => {
useEffect(() => {
if (items.length === 0) onLoadMore();
}, []);
return (
<div className="studio-projects">
<h2><FormattedMessage id="studio.projectsHeader" /></h2>
{canEditOpenToAll && <StudioOpenToAll />}
{canAddProjects && <StudioProjectAdder />}
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-projects-grid">
{items.length === 0 && !loading ? (
<div className="studio-empty">
{canAddProjects ? (
<React.Fragment>
<img
width="388"
height="265"
className="studio-empty-img"
src="/images/studios/projects-empty-can-add.png"
<AlertProvider>
<div className="studio-projects">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.projectsHeader" /></h2>
{canEditOpenToAll && <StudioOpenToAll />}
</div>
{showMuteError &&
<CommentingStatus>
<p>
<div>
<FormattedMessage
id="studios.mutedProjects"
values={{
inDuration: formatRelativeTime(muteExpiresAtMs, window._locale)
}}
/>
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.projectsEmptyCanAdd1" /></div>
<div><FormattedMessage id="studio.projectsEmptyCanAdd2" /></div>
</div>
</React.Fragment>
) : (
<React.Fragment>
<img
width="186"
height="138"
className="studio-empty-img"
src="/images/studios/projects-empty.png"
/>
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.projectsEmpty1" /></div>
<div><FormattedMessage id="studio.projectsEmpty2" /></div>
</div>
</React.Fragment>
)}
</div>
) : (
<React.Fragment>
{items.map(item =>
(<StudioProjectTile
fetching={loading}
key={item.id}
id={item.id}
title={item.title}
image={item.image}
avatar={item.avatar['90x90']}
username={item.username}
addedBy={item.actor_id}
/>)
)}
{moreToLoad &&
</div>
<div><FormattedMessage id="studios.mutedPaused" /></div>
</p>
</CommentingStatus>
}
{canAddProjects && <StudioProjectAdder />}
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-projects-grid">
{items.length === 0 && !loading ? (
<div className="studio-empty">
{canAddProjects ? (
<React.Fragment>
<img
width="388"
height="265"
className="studio-empty-img"
src="/images/studios/projects-empty-can-add.png"
/>
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.projectsEmptyCanAdd1" /></div>
<div><FormattedMessage id="studio.projectsEmptyCanAdd2" /></div>
</div>
</React.Fragment>
) : (
<React.Fragment>
<img
width="186"
height="138"
className="studio-empty-img"
src="/images/studios/projects-empty.png"
/>
<div className="studio-empty-msg">
<div><FormattedMessage id="studio.projectsEmpty1" /></div>
{!isMuted && <div><FormattedMessage id="studio.projectsEmpty2" /></div>}
</div>
</React.Fragment>
)}
</div>
) : (
<React.Fragment>
{items.map(item =>
(<StudioProjectTile
fetching={loading}
key={item.id}
id={item.id}
title={item.title}
image={item.image}
avatar={item.avatar['90x90']}
username={item.username}
addedBy={item.actor_id}
/>)
)}
{moreToLoad &&
<div className="studio-projects-load-more">
<button
className={classNames('button', {
@ -84,11 +109,12 @@ const StudioProjects = ({
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</React.Fragment>
)}
}
</React.Fragment>
)}
</div>
</div>
</div>
</AlertProvider>
);
};
@ -103,17 +129,23 @@ StudioProjects.propTypes = {
title: PropTypes.string,
username: PropTypes.string
})),
isMuted: PropTypes.bool,
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
muteExpiresAtMs: PropTypes.number,
onLoadMore: PropTypes.func,
showMuteError: PropTypes.bool
};
export default connect(
state => ({
...projects.selector(state),
canAddProjects: selectCanAddProjects(state),
canEditOpenToAll: selectCanEditOpenToAll(state)
canEditOpenToAll: selectCanEditOpenToAll(state),
isMuted: selectIsMuted(state),
showMuteError: selectShowProjectMuteError(state),
muteExpiresAtMs: (selectMuteStatus(state).muteExpiresAt * 1000 || 0)
}),
{
onLoadMore: loadProjects

View file

@ -4,93 +4,45 @@ import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import StudioReportModal from './modals/studio-report-modal.jsx';
import {
Fields,
actions,
selectors
} from '../../redux/studio-report';
import reportIcon from './icons/report-icon.svg';
const StudioReport = ({
canReport,
error,
field,
isOpen,
isSubmitting,
previouslyReported,
handleSetField,
handleOpen,
handleClose,
handleSubmit
handleOpen
}) => (
<div>
<h3>Reporting</h3>
{canReport && (
<button onClick={handleOpen}><FormattedMessage id="general.report" /></button>
)}
{canReport &&
<button onClick={handleOpen}>
<img src={reportIcon} />
<FormattedMessage id="general.report" />
</button>
}
{isOpen && (
<div style={{padding: '1rem', margin: '1rem', border: '1px solid green'}}>
<div><FormattedMessage id="report.studio" /></div>
{previouslyReported ? (
<React.Fragment>
<div>Submitted the report!</div>
<button onClick={handleClose}><FormattedMessage id="general.close" /></button>
</React.Fragment>
) : (
<React.Fragment>
<select
value={field}
onChange={e => handleSetField(e.target.value)}
>
<option value={Fields.TITLE}><FormattedMessage id="studio.title" /></option>
<option value={Fields.DESCRIPTION}><FormattedMessage id="studio.description" /></option>
<option value={Fields.THUMBNAIL}><FormattedMessage id="studio.thumbnail" /></option>
</select>
{error && (
<div>
<div>There was an error. Try again later?</div>
<div><code><pre>{error}</pre></code></div>
</div>
)}
<button
disabled={isSubmitting}
onClick={handleSubmit}
>
<FormattedMessage id="report.send" />
</button>
<button onClick={handleClose}><FormattedMessage id="general.cancel" /></button>
</React.Fragment>
)}
</div>
<StudioReportModal />
)}
</div>
);
StudioReport.propTypes = {
canReport: PropTypes.bool,
error: PropTypes.string,
field: PropTypes.string,
isOpen: PropTypes.bool,
isSubmitting: PropTypes.bool,
previouslyReported: PropTypes.bool,
handleOpen: PropTypes.func,
handleClose: PropTypes.func,
handleSetField: PropTypes.func,
handleSubmit: PropTypes.func
handleOpen: PropTypes.func
};
export default connect(
state => ({
canReport: selectors.selectCanReportStudio(state),
error: selectors.selectStudioReportError(state),
field: selectors.selectStudioReportField(state),
isOpen: selectors.selectStudioReportOpen(state),
isSubmitting: selectors.selectStudioReportSubmitting(state),
previouslyReported: selectors.selectStudioReportSubmitted(state)
isOpen: selectors.selectStudioReportOpen(state)
}),
{
handleOpen: actions.openStudioReport,
handleClose: actions.closeStudioReport,
handleSetField: actions.setStudioReportField,
handleSubmit: actions.submitStudioReport
handleOpen: actions.openStudioReport
}
)(StudioReport);

View file

@ -0,0 +1,48 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import {selectIsFetchingInfo, selectStudioFollowerCount, selectStudioLastUpdated} from '../../redux/studio';
import lastUpdatedIcon from './icons/last-updated-icon.svg';
import followersIcon from './icons/followers-icon.svg';
const StudioStats = ({
isFetchingInfo,
followerCount,
lastUpdatedDate
}) => {
if (isFetchingInfo) return <React.Fragment />;
return (<React.Fragment>
<div><img
src={lastUpdatedIcon}
/><FormattedMessage
id="studio.lastUpdated"
values={{lastUpdatedDate}}
/></div>
<div><img
src={followersIcon}
/><FormattedMessage
id="studio.followerCount"
values={{followerCount}}
/></div>
</React.Fragment>);
};
StudioStats.propTypes = {
isFetchingInfo: PropTypes.bool,
followerCount: PropTypes.number,
lastUpdatedDate: PropTypes.instanceOf(Date)
};
export default connect(
state => ({
isFetchingInfo: selectIsFetchingInfo(state),
followerCount: selectStudioFollowerCount(state),
lastUpdatedDate: selectStudioLastUpdated(state)
}),
{
}
)(StudioStats);

View file

@ -1,9 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {useRouteMatch, NavLink} from 'react-router-dom';
import SubNavigation from '../../components/subnavigation/subnavigation.jsx';
import {FormattedMessage} from 'react-intl';
const StudioTabNav = () => {
import SubNavigation from '../../components/subnavigation/subnavigation.jsx';
import activityIcon from './icons/activity-icon.svg';
import commentsIcon from './icons/comments-icon.svg';
import curatorsIcon from './icons/curator-icon.svg';
import projectsIcon from './icons/projects-icon.svg';
import {selectIsFetchingInfo, selectStudioCommentCount, selectStudioProjectCount} from '../../redux/studio';
/**
* Format a number to a string. If the number is below the limit, format as-is. Otherwise, show a '+' to indicate that
* the actual number might be higher.
* @example
* limitCount(1, 100) == '1'
* limitCount(12.5, 100) == '12.5'
* limitCount(100, 100) == '100+'
* limitCount(999, 100) == '100+'
* @param {number} num - the number to format
* @param {number} limit - the number at which we start showing a '+'
* @returns {string} - a string representing a number, possibly with a '+' at the end
*/
const limitCount = (num, limit) => {
if (num < limit) {
return `${num}`;
}
return `${limit}+`;
};
// These must match the limits used by the API
const countLimits = {
comments: 100,
projects: 100
};
const StudioTabNav = ({isFetchingInfo, commentCount, projectCount}) => {
const {params: {studioPath, studioId}} = useRouteMatch();
const base = `/${studioPath}/${studioId}`;
return (
@ -16,28 +52,68 @@ const StudioTabNav = () => {
to={base}
exact
>
<li><FormattedMessage id="studio.tabNavProjects" /></li>
<li><img
src={projectsIcon}
/><FormattedMessage
id={isFetchingInfo ? 'studio.tabNavProjects' : 'studio.tabNavProjectsWithCount'}
values={{
projectCount: (
<span className="tab-count">
({limitCount(projectCount, countLimits.projects)})
</span>
)
}}
/></li>
</NavLink>
<NavLink
activeClassName="active"
to={`${base}/comments`}
>
<li><FormattedMessage id="studio.tabNavComments" /></li>
<li><img
src={commentsIcon}
/><FormattedMessage
id={isFetchingInfo ? 'studio.tabNavComments' : 'studio.tabNavCommentsWithCount'}
values={{
commentCount: (
<span className="tab-count">
({limitCount(commentCount, countLimits.comments)})
</span>
)
}}
/></li>
</NavLink>
<NavLink
activeClassName="active"
to={`${base}/curators`}
>
<li><FormattedMessage id="studio.tabNavCurators" /></li>
<li><img
src={curatorsIcon}
/><FormattedMessage id="studio.tabNavCurators" /></li>
</NavLink>
<NavLink
activeClassName="active"
to={`${base}/activity`}
>
<li><FormattedMessage id="studio.tabNavActivity" /></li>
<li><img
src={activityIcon}
/><FormattedMessage id="studio.tabNavActivity" /></li>
</NavLink>
</SubNavigation>
);
};
export default StudioTabNav;
StudioTabNav.propTypes = {
isFetchingInfo: PropTypes.bool,
commentCount: PropTypes.number,
projectCount: PropTypes.number
};
const mapStateToProps = state => ({
isFetchingInfo: selectIsFetchingInfo(state),
commentCount: selectStudioCommentCount(state),
projectCount: selectStudioProjectCount(state)
});
const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(StudioTabNav);

View file

@ -1,31 +1,63 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import {selectStudioTitle, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {mutateStudioTitle, selectIsMutatingTitle, selectTitleMutationError} from '../../redux/studio-mutations';
import classNames from 'classnames';
import {selectCanEditInfo, selectShowEditMuteError} from '../../redux/studio-permissions';
import {Errors, mutateStudioTitle, selectIsMutatingTitle, selectTitleMutationError} from '../../redux/studio-mutations';
import ValidationMessage from '../../components/forms/validation-message.jsx';
import StudioMuteEditMessage from './studio-mute-edit-message.jsx';
const errorToMessageId = error => {
switch (error) {
case Errors.INAPPROPRIATE: return 'studio.updateErrors.inappropriate';
case Errors.TEXT_TOO_LONG: return 'studio.updateErrors.textTooLong';
case Errors.REQUIRED_FIELD: return 'studio.updateErrors.requiredField';
default: return 'studio.updateErrors.generic';
}
};
const StudioTitle = ({
titleError, isFetching, isMutating, title, canEditInfo, handleUpdate
titleError, isFetching, isMutating, isMutedEditor, title, canEditInfo, handleUpdate
}) => {
const fieldClassName = classNames('studio-title', {
'mod-fetching': isFetching,
'mod-mutating': isMutating
'mod-mutating': isMutating,
'mod-form-error': !!titleError,
'muted-editor': isMutedEditor
});
const [showMuteMessage, setShowMuteMessage] = useState(false);
return (
<React.Fragment>
<textarea
className={fieldClassName}
disabled={isMutating || !canEditInfo || isFetching}
defaultValue={title}
onBlur={e => e.target.value !== title &&
handleUpdate(e.target.value)}
/>
{titleError && <div>Error mutating title: {titleError}</div>}
</React.Fragment>
<div
className="studio-info-section"
onMouseEnter={() => isMutedEditor && setShowMuteMessage(true)}
onMouseLeave={() => isMutedEditor && setShowMuteMessage(false)}
>
{canEditInfo || isMutedEditor ? (
<React.Fragment>
<textarea
className={fieldClassName}
disabled={isMutating || !canEditInfo || isFetching}
defaultValue={title}
onKeyDown={e => e.key === 'Enter' && e.target.blur()}
onBlur={e => e.target.value !== title &&
handleUpdate(e.target.value)}
/>
{titleError && <ValidationMessage
mode="error"
message={<FormattedMessage id={errorToMessageId(titleError)} />}
/>}
{showMuteMessage && <StudioMuteEditMessage />}
</React.Fragment>
) : (
<div className={fieldClassName}>{title}</div>
)}
</div>
);
};
@ -34,6 +66,7 @@ StudioTitle.propTypes = {
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
isMutedEditor: PropTypes.bool,
title: PropTypes.string,
handleUpdate: PropTypes.func
};
@ -44,6 +77,7 @@ export default connect(
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingTitle(state),
isMutedEditor: selectShowEditMuteError(state),
titleError: selectTitleMutationError(state)
}),
{

View file

@ -22,6 +22,9 @@ import StudioManagers from './studio-managers.jsx';
import StudioCurators from './studio-curators.jsx';
import StudioComments from './studio-comments.jsx';
import StudioActivity from './studio-activity.jsx';
import StudioCuratorInvite from './studio-curator-invite.jsx';
import StudioMeta from './studio-meta.jsx';
import StudioAdminPanel from './studio-admin-panel.jsx';
import {
projects,
@ -37,14 +40,20 @@ const {commentsReducer} = require('../../redux/comments');
const {studioMutationsReducer} = require('../../redux/studio-mutations');
import './studio.scss';
import {selectMuteStatus} from '../../redux/session.js';
import {formatRelativeTime} from '../../lib/format-time.js';
import CommentingStatus from '../../components/commenting-status/commenting-status.jsx';
import {FormattedMessage} from 'react-intl';
import {selectShowCuratorMuteError} from '../../redux/studio-permissions.js';
const StudioShell = ({studioLoadFailed}) => {
const StudioShell = ({showCuratorMuteError, muteExpiresAtMs, studioLoadFailed}) => {
const match = useRouteMatch();
return (
studioLoadFailed ?
<NotAvailable /> :
<div className="studio-shell">
<StudioMeta />
<div className="studio-info">
<StudioInfo />
</div>
@ -53,6 +62,22 @@ const StudioShell = ({studioLoadFailed}) => {
<div>
<Switch>
<Route path={`${match.path}/curators`}>
<StudioCuratorInvite />
{showCuratorMuteError &&
<CommentingStatus>
<p>
<div>
<FormattedMessage
id="studios.mutedCurators"
values={{
inDuration: formatRelativeTime(muteExpiresAtMs, window._locale)
}}
/>
</div>
<div><FormattedMessage id="studios.mutedPaused" /></div>
</p>
</CommentingStatus>
}
<StudioManagers />
<StudioCurators />
</Route>
@ -77,17 +102,22 @@ const StudioShell = ({studioLoadFailed}) => {
};
StudioShell.propTypes = {
showCuratorMuteError: PropTypes.bool,
muteExpiresAtMs: PropTypes.number,
studioLoadFailed: PropTypes.bool
};
const ConnectedStudioShell = connect(
state => ({
studioLoadFailed: selectStudioLoadFailed(state)
showCuratorMuteError: selectShowCuratorMuteError(state),
studioLoadFailed: selectStudioLoadFailed(state),
muteExpiresAtMs: (selectMuteStatus(state).muteExpiresAt * 1000 || 0)
}),
)(StudioShell);
render(
<Page className="studio-page">
<StudioAdminPanel />
<Router>
<Switch>
{/* Use variable studioPath to support /studio-playground/ or future route */}

View file

@ -13,6 +13,20 @@ $radius: 8px;
min-width: auto;
margin: 50px auto;
display: block;
padding-top: 40px;
&.mod-view-admin-panel-open {
min-width: unset;
width: calc(100% - 250px);
margin: 50px 0px 50px 250px;
}
/* WAT Why does everything center at smaller widths??!! */
@media #{$intermediate-and-smaller} {
& {
text-align: unset !important;
}
}
.studio-shell {
padding: 0 20px;
@ -40,34 +54,139 @@ $radius: 8px;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
.studio-title, .studio-description {
background: transparent;
margin: 0 -8px; /* Outset the border horizontally */
padding: 5px 8px;
border: 2px dashed $ui-blue-25percent;
border-radius: $radius;
resize: none;
&:disabled { border-color: transparent; }
.studio-info-section {
position: relative;
.validation-message {
margin-top: .5rem;
box-sizing: border-box;
}
.hidden {
display: none;
}
}
.studio-info-footer {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.studio-info-footer-stats {
justify-content: flex-start;
div {
display: flex;
align-items: center;
margin: 0.25em;
img {
margin-right: 0.25em;
width: 1.5em;
}
}
}
.studio-info-footer-report {
justify-content: flex-end;
button {
font-size: smaller;
background-color: $ui-blue;
border: 1px solid transparent;
border-radius: 999em;
color: $ui-white;
display: flex;
align-items: center;
padding: 0.25em;
padding-right: 0.75em;
&:hover {
background-color: $ui-blue-dark;
}
img {
margin-right: 0.25em;
width: 1.5em;
}
}
}
.studio-title {
font-size: 28px;
font-weight: 500;
font-weight: 700;
}
.studio-description:disabled {
.studio-description {
background: $ui-blue-10percent;
padding: 15px 20px;
border-color: transparent;
border-radius: $radius;
word-wrap: break-word;
white-space: pre-wrap;
width: 300px;
box-sizing: border-box;
height: 24rem;
overflow-y: scroll;
&.muted-editor {
@media #{$intermediate-and-smaller} {
height: 18rem;
}
}
}
/* Overrides for when title and description are editable textareas */
textarea.studio-title, textarea.studio-description {
background: transparent;
padding: 5px 8px;
border: 2px dashed $ui-blue-25percent;
border-radius: $radius;
resize: none;
width: 300px;
box-sizing: border-box;
}
.studio-image {
width: 300px;
height: 225px;
object-fit: cover;
border-radius: 8px;
}
.studio-follow-button {
padding-top: 14px;
padding-bottom: 14px;
font-size: 14px;
margin: 0;
}
}
.studio-tab-nav {
border-bottom: 1px solid $active-dark-gray;
padding-bottom: 8px;
li { background: rgba(0, 0, 0, 0.15); }
font-size: 14px;
li {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.15);
padding: 0.5em 0.75em 0.5em 0.5em;
&:active {
padding: calc(0.5em + 1px) calc(0.75em + 1px) calc(0.5em + 1px) calc(0.5em + 1px);
}
img {
margin-right: 0.5em;
width: 1.5em;
}
.tab-count {
font-weight: normal;
}
}
.active > li { background: $ui-blue; }
}
.studio-projects {}
.studio-projects, .studio-members {
position: relative;
}
.studio-projects-grid {
margin-top: 20px;
display: grid;
@ -100,10 +219,13 @@ $radius: 8px;
background: #a0c6fc;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.studio-project-bottom {
display: flex;
padding: 10px 6px 10px 12px;
padding: 6px 4px 6px 10px;
justify-content: space-between;
}
.studio-project-avatar {
@ -144,7 +266,6 @@ $radius: 8px;
}
}
.studio-members {}
.studio-members-grid {
margin-top: 20px;
display: grid;
@ -220,30 +341,50 @@ $radius: 8px;
color: #4C97FF;
}
.flex-row {
margin: 0 -6px;
& > * {
margin: 0 6px;
.studio-adder-row {
display: flex;
flex-wrap: wrap-reverse; /* so error goes below at small sizes */
.studio-adder-error {
position: relative;
.validation-message {
transform: none;
width: 200px;
}
@media #{$intermediate-and-smaller} {
& {
width: 100%;
margin-top: .5rem;
.validation-message {
max-width: 100%;
width: 100%;
box-sizing: border-box;
}
}
}
}
}
input {
flex-grow: 1;
display: inline-block;
margin: .5em 0;
border: 1px solid $ui-border;
border-radius: .5rem;
padding: 1em 1.25em;
font-size: .8rem;
}
input {
flex-grow: 1;
display: inline-block;
border: 1px solid $ui-border;
border-radius: .5rem;
padding: 1em 1.25em;
font-size: .8rem;
margin-inline-end: 6px;
}
button {
flex-grow: 0;
}
button {
flex-grow: 0;
margin: 0;
}
.studio-adder-vertical-divider {
border: 1px solid $ui-border;
align-self: stretch;
.studio-adder-vertical-divider {
margin: 0 6px;
border: 1px solid $ui-border;
align-self: stretch;
}
}
}
@ -267,6 +408,23 @@ $radius: 8px;
}
}
.studio-header-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-top: 20px;
padding-bottom: 10px;
h2 {
font-size: 28px;
}
}
.studio-compose-container {
padding-top: 8px;
}
.studio-empty {
grid-column: 1 / -1; /* take up all columns */
text-align: center;
@ -283,6 +441,76 @@ $radius: 8px;
}
}
.studio-invitation {
margin-top: 1rem;
padding: 1rem;
box-sizing: border-box;
min-height: 85px; /* So the box doesn't change height after being accepted */
display: flex;
justify-content: space-between;
align-items: center;
@media #{$intermediate-and-smaller} {
flex-direction: column;
.studio-invitation-msg {
margin-top: .5rem;
margin-bottom: 1rem;
}
}
}
.studio-info-box {
border-radius: 4px;
background: $ui-blue-10percent;
border: 1px solid $ui-blue-25percent;
&.studio-info-box-success {
background: #CEF2E8;
border: 1px solid rgba(15, 189, 140, 0.5);
}
&.studio-info-box-error {
background: #FFF0DF;
border: 1px solid $ui-dark-orange;
}
}
.studio-thumb-edit-button {
color: #4C97FF;
font-size: 1rem;
font-weight: bold;
height: 40px;
display: flex;
align-items: center;
}
.studio-thumb-edit-img {
padding-right: 10px;
}
.studio-admin-panel {
margin-top: 51px;
border: 0;
padding: .5rem;
overflow: hidden;
}
.studio-admin-panel.admin-panel-open {
padding: 0;
width: 250px;
}
.admin-iframe {
position: absolute;
top: 0;
left: 0;
z-index: 100;
margin: 0;
border: 0;
width: 250px;
height: 100%;
}
/* Modification classes for different interaction states */
.mod-fetching { /* When a field has no content to display yet */
position: relative;
@ -315,3 +543,11 @@ $radius: 8px;
.mod-clickable {
cursor: pointer;
}
.mod-form-error { /* When a field contains a value is causing an error */
border-color: $ui-orange !important;
}
.studio-curator-mute-box {
margin: 20px 0;
}

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.86144 15.403C7.43527 15.403 7.0091 15.2398 6.68447 14.9152L3.48818 11.7189C2.83727 11.068 2.83727 10.0159 3.48818 9.36498C4.13909 8.71407 5.19121 8.71407 5.84212 9.36498L7.86144 11.3843L14.1591 5.08828C14.8084 4.43737 15.8622 4.43737 16.5131 5.08828C17.1623 5.73753 17.1623 6.7913 16.5131 7.44222L9.03841 14.9152C8.71378 15.2398 8.28761 15.403 7.86144 15.403Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path class="st0" d="M9.5,12.8c0,0.8-0.6,1.5-1.5,1.5c-0.4,0-0.8-0.2-1.1-0.5c-0.3-0.3-0.5-0.6-0.5-1.1V9.5H3.2
C2.8,9.5,2.5,9.3,2.2,9S1.7,8.3,1.7,7.9c0-0.8,0.6-1.5,1.5-1.5l3.3,0.1L6.4,3.2C6.5,2.4,7.2,1.7,8,1.6c0.8,0.1,1.5,0.8,1.6,1.6
L9.5,6.5h3.3c0.8,0,1.5,0.6,1.5,1.5s-0.6,1.5-1.5,1.5l-3.3,0L9.5,12.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

View file

@ -0,0 +1,20 @@
<svg width="130" height="191" viewBox="0 0 130 191" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="3" width="130" height="188">
<rect y="3" width="130" height="188" fill="#C4C4C4"/>
</mask>
<g mask="url(#mask0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.00015 190.5C-61.0204 187.192 -53.9928 175.861 -57.4998 99C-60.4497 34.3467 -28.917 3.00052 32.7026 3.00052C109.489 3.00052 131.182 33.3704 128.968 98.3174C126.616 167.302 49.6022 193 2.00015 190.5Z" fill="#0FBD8C" fill-opacity="0.25"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-26.3462 63.6609C-26.7399 61.5295 -25.3644 59.4724 -23.2444 59.0217L59.0989 41.5191C61.2301 41.0661 63.3318 42.4021 63.8262 44.524L78.131 105.916C78.3677 106.932 78.1983 108 77.659 108.892L64.858 130.08C64.3088 130.989 63.4205 131.642 62.3891 131.896L-6.31782 148.784C-8.55219 149.334 -10.7881 147.889 -11.2061 145.626L-26.3462 63.6609Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M63 111.166L79 107L64.3913 132L63 111.166Z" fill="#0EBD8C" fill-opacity="0.1"/>
<rect x="-24.9741" y="73.2256" width="81" height="9" rx="4.5" transform="rotate(-13 -24.9741 73.2256)" fill="#575E75" fill-opacity="0.25"/>
<rect x="-22.9741" y="90.2256" width="81" height="9" rx="4.5" transform="rotate(-13 -22.9741 90.2256)" fill="#575E75" fill-opacity="0.25"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-6.54849 109.48C-7.10587 107.066 -5.6067 104.655 -3.19484 104.088L21.4033 98.298C23.8297 97.7269 26.2581 99.2371 26.8189 101.666C27.3762 104.08 25.8771 106.491 23.4652 107.059L-1.13289 112.848C-3.55935 113.419 -5.98774 111.909 -6.54849 109.48Z" fill="#575E75" fill-opacity="0.25"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M104.085 69.4963L102.113 65.7763C100.679 63.0733 96.7551 62.324 94.2109 63.6475L93.2085 64.1701L93.2078 64.1695L31.1812 96.4579L31.123 96.4879L29.9175 98.7689L25.7672 106.62C25.5442 107.041 25.7175 107.68 26.0654 108.12C26.2715 108.384 26.5406 108.578 26.8254 108.612L30.9788 109.111L30.9763 109.106L36.8018 109.807L38.2781 109.985L38.3157 109.89L38.3472 109.949L91.7703 82.1391L91.7709 82.1403L94.5218 80.7085L94.5206 80.7073L95.5303 80.1817L95.5309 80.1829L98.2818 78.7511L98.2812 78.7493L100.039 77.8346L100.049 77.8539L101.368 77.1671C103.915 75.8411 105.52 72.203 104.085 69.4963Z" fill="#0EBD8C" fill-opacity="0.25"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.0137 107.407L97.1118 71.5674L89.2196 58.4067L29.1118 94.2522L37.0137 107.407Z" fill="#FFBF00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.3123 99.6064L31.7587 98.5862L32.6907 95.2529L28.9264 94.22L28.8699 94.2538L27.791 96.6099L24.0771 104.722C23.7596 105.415 24.4766 106.608 25.241 106.658L35.2923 107.314L36.7792 107.411L37.9248 103.945L34.3761 103.128L35.3123 99.6064Z" fill="#CF8B17"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.8201 100.933L24.0825 104.727C23.7649 105.42 24.4826 106.614 25.2463 106.664L29.429 106.937L25.8201 100.933Z" fill="#5C6771"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M89.9165 75.9072L82 62.7546L88.307 58.9998L96.2235 72.1517L89.9165 75.9072Z" fill="#36A97E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M96.7747 71.7455L88.8545 58.5699L90.1435 57.8031C92.6139 56.333 96.5816 56.8694 98.1656 59.5049L100.346 63.1315C101.932 65.77 100.529 69.5121 98.0557 70.9835L96.7747 71.7455Z" fill="#ED5F87"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.292 99.1195C93.6848 98.7458 94.7728 97.6621 95.148 96.2748L96.0011 93.1187C96.4042 91.6268 98.5292 91.6268 98.933 93.1187L99.786 96.2748C100.161 97.6621 101.249 98.7458 102.641 99.1195L105.811 99.9692C107.308 100.371 107.308 102.487 105.811 102.889L102.641 103.739C101.249 104.112 100.161 105.196 99.786 106.584L98.933 109.74C98.5292 111.231 96.4042 111.231 96.0011 109.74L95.148 106.584C94.7728 105.196 93.6848 104.112 92.292 103.739L89.1234 102.889C87.6255 102.487 87.6255 100.371 89.1234 99.9692L92.292 99.1195Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -50,6 +50,18 @@
"social": false
}
}
},
"user1Muted": {
"session": {
"user": {
"id": 1,
"username": "user1-username"
},
"permissions": {
"mute_status": {"muteExpiresAt": 32515480478, "offenses": [], "showWarning": false},
"social": true
}
}
}
}
}

View file

@ -5,7 +5,8 @@ require('chromedriver');
const headless = process.env.SMOKE_HEADLESS || false;
const remote = process.env.SMOKE_REMOTE || false;
const ci = process.env.CI || false;
const buildID = process.env.TRAVIS_BUILD_NUMBER;
const usingCircle = process.env.CIRCLECI || false;
const buildID = process.env.CIRCLE_BUILD_NUM || '0000';
const {SAUCE_USERNAME, SAUCE_ACCESS_KEY} = process.env;
const {By, Key, until} = webdriver;
@ -35,7 +36,8 @@ class SeleniumHelper {
if (remote === 'true'){
let nameToUse;
if (ci === 'true'){
nameToUse = 'travis ' + buildID + ' : ' + name;
let ciName = usingCircle ? 'circleCi ' : 'unknown ';
nameToUse = ciName + buildID + ' : ' + name;
} else {
nameToUse = name;
}

View file

@ -1,123 +0,0 @@
/*
* Checks that the links in the navbar on the homepage have the right URLs to redirect to
*
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
*/
const SeleniumHelper = require('../selenium-helpers.js');
const helper = new SeleniumHelper();
var tap = require('tap');
const webdriver = require('selenium-webdriver');
const driver = helper.buildDriver('www-smoke test_navbar_links');
// Set test url through environment variable
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
// number of tests in the plan
tap.plan(7);
tap.tearDown(function () {
// quit the instance of the browser
driver.quit();
});
tap.beforeEach(function () {
// load the page with the driver
return driver.get(rootUrl);
});
// ==== Links in navbar ====
// the create link changes depending on whether the user is signed in or not (tips window opens)
tap.test('checkCreateLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "create")]/a';
var expectedHref = '/projects/editor/?tutorial=getStarted';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getAttribute('href');
})
.then(function (url) {
t.equal(url.substr(-expectedHref.length), expectedHref);
t.end();
});
});
tap.test('checkExploreLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "explore")]/a';
var expectedHref = '/explore/projects/all';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getAttribute('href');
})
.then(function (url) {
t.equal(url.substr(-expectedHref.length), expectedHref);
t.end();
});
});
tap.test('checkIdeasLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "ideas")]/a';
var expectedHref = '/ideas';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getAttribute('href');
})
.then(function (url) {
t.equal(url.substr(-expectedHref.length), expectedHref);
t.end();
});
});
tap.test('checkAboutLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "about")]/a';
var expectedHref = '/about';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getAttribute('href');
})
.then(function (url) {
t.equal(url.substr(-expectedHref.length), expectedHref);
t.end();
});
});
// ==== Search bar ====
tap.test('checkSearchBar', function (t) {
var xPathLink = '//input[@id="frc-q-1088"]';
// search bar should exist
driver.findElement(webdriver.By.xpath(xPathLink)).then(function (element) {
t.ok(element);
t.end();
});
});
// ==== Join Scratch & Sign In ====
tap.test('checkJoinScratchLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "right") and contains(@class, "join")]/a';
var expectedText = 'Join Scratch';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getText('a');
})
.then(function (text) {
t.equal(text, expectedText);
t.end();
});
});
tap.test('checkSignInLinkWhenSignedOut', function (t) {
var xPathLink = '//li[contains(@class, "link") and contains(@class, "right") and contains(@class, "login-item")]/a';
var expectedText = 'Sign in';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
return element.getText('a');
})
.then(function (text) {
t.equal(text, expectedText);
t.end();
});
});

View file

@ -1,93 +0,0 @@
/*
* Checks that the some of the homepage rows on the homepage are displayed and
* contents have the right URLs to redirect to
*
* Test cases: https://github.com/LLK/scratch-www/wiki/Most-Important-Workflows
*/
const SeleniumHelper = require('../selenium-helpers.js');
const helper = new SeleniumHelper();
var tap = require('tap');
const webdriver = require('selenium-webdriver');
const driver = helper.buildDriver('www-smoke test_project_rows');
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
// number of tests in the plan
tap.plan(4);
tap.tearDown(function () {
// quit the instance of the browser
driver.quit();
});
tap.beforeEach(function () {
// load the page with the driver
return driver.get(rootUrl);
});
// checks that the title of the first row is Featured Projects
tap.test('checkFeaturedProjectsRowTitleWhenSignedOut', function (t) {
var xPathLink = '//div[@class="box"]/div[@class="box-header"]/h4';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
element.getText('h4')
.then(function (text) {
// expected value of the title text
var expectedText = 'Featured Projects';
t.equal(text, expectedText);
t.end();
});
});
});
// checks that the link for a project makes sense
tap.test('checkFeaturedProjectsRowLinkWhenSignedOut', function (t) {
var xPathLink = '//div[contains(@class, "thumbnail") ' +
'and contains(@class, "project") and contains(@class, "slick-slide") ' +
'and contains(@class, "slick-active")]/a[@class="thumbnail-image"]';
driver.wait(webdriver.until
.elementLocated(webdriver.By.xpath(xPathLink)))
.then(function (element) {
element.getAttribute('href')
.then(function (url) {
// expected pattern for the project URL
// since I don't know the length of the project ID number
var expectedUrlRegExp = new RegExp('/projects/.*[0-9].*/?');
t.match(url, expectedUrlRegExp);
t.end();
});
});
});
// checks that the title of the 2nd row is Featured Studios
tap.test('checkFeaturedStudiosRowWhenSignedOut', function (t) {
var xPathLink = '//div[@class="box"][2]/div[@class="box-header"]/h4';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
element.getText('h4')
.then(function (text) {
var expectedText = 'Featured Studios';
t.equal(text, expectedText);
t.end();
});
});
});
// checks that the link for a studio makes sense
tap.test('checkFeaturedStudiosRowLinkWhenSignedOut', function (t) {
var xPathLink = '//div[contains(@class, "thumbnail") and contains(@class, "gallery") ' +
'and contains(@class, "slick-slide") ' +
'and contains(@class, "slick-active")]/a[@class="thumbnail-image"]';
driver.findElement(webdriver.By.xpath(xPathLink))
.then(function (element) {
element.getAttribute('href')
.then(function (url) {
var expectedUrlRegExp = new RegExp('/studios/.*[0-9].*/?');
t.match(url, expectedUrlRegExp);
t.end();
});
});
});

View file

@ -0,0 +1,59 @@
const SeleniumHelper = require('./selenium-helpers.js');
const {
clickXpath,
findByXpath,
buildDriver
} = new SeleniumHelper();
let remote = process.env.SMOKE_REMOTE || false;
let rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
if (remote) {
jest.setTimeout(60000);
} else {
jest.setTimeout(20000);
}
let driver;
describe('www-integration project rows', () => {
beforeAll(async () => {
driver = await buildDriver('www-integration project rows');
// driver.get(rootUrl);
});
beforeEach(async () => {
await driver.get(rootUrl);
});
afterAll(async () => await driver.quit());
test('Featured Projects row title', async () => {
let projects = await findByXpath('//div[@class="box"]/div[@class="box-header"]/h4');
let projectsText = await projects.getText();
await expect(projectsText).toEqual('Featured Projects');
});
test('Featured Project link', async () => {
await clickXpath('//div[@class="box"][descendant::text()="Featured Projects"]' +
'//div[contains(@class, "thumbnail")][1]/a[@class="thumbnail-image"]');
let guiPlayer = await findByXpath('//div[@class="guiPlayer"]');
let guiPlayerDisplayed = await guiPlayer.isDisplayed();
await expect(guiPlayerDisplayed).toBe(true);
});
test('Featured Studios row title', async () => {
let studios = await findByXpath('//div[@class="box"][2]/div[@class="box-header"]/h4');
let studiosText = await studios.getText();
await expect(studiosText).toEqual('Featured Studios');
});
test('Featured Studios link', async () => {
await clickXpath('//div[@class="box"][descendant::text()="Featured Studios"]' +
'//div[contains(@class, "thumbnail")][1]/a[@class="thumbnail-image"]');
let galleryInfo = await findByXpath('//div[contains(@class, "gallery-info")]');
let galleryInfoDisplayed = await galleryInfo.isDisplayed();
await expect(galleryInfoDisplayed).toBe(true);
});
});

View file

@ -0,0 +1,106 @@
const SeleniumHelper = require('./selenium-helpers.js');
const {
clickXpath,
findByXpath,
buildDriver
} = new SeleniumHelper();
let remote = process.env.SMOKE_REMOTE || false;
let rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
if (remote) {
jest.setTimeout(60000);
} else {
jest.setTimeout(10000);
}
let driver;
describe('www-integration navbar links', () => {
beforeAll(async () => {
driver = await buildDriver('www-integration navbar links');
});
beforeEach(async () => {
await driver.get(rootUrl);
});
afterAll(async () => await driver.quit());
test('Check text of navbar items', async () => {
let create = await findByXpath('//li[@class="link create"]');
let createText = await create.getText();
await expect(createText).toEqual('Create');
let explore = await findByXpath('//li[@class="link explore"]');
let exploreText = await explore.getText();
await expect(exploreText).toEqual('Explore');
let ideas = await findByXpath('//li[@class="link ideas"]');
let ideasText = await ideas.getText();
await expect(ideasText).toEqual('Ideas');
let about = await findByXpath('//li[@class="link about"]');
let aboutText = await about.getText();
await expect(aboutText).toEqual('About');
let join = await findByXpath('//a[@class="registrationLink"]');
let joinText = await join.getText();
await expect(joinText).toEqual('Join Scratch');
let signIn = await findByXpath('//li[@class="link right login-item"]/a');
let signInText = await signIn.getText();
await expect(signInText).toEqual('Sign in');
});
test('create when signed out', async () => {
await clickXpath('//li[@class="link create"]');
let gui = await findByXpath('//div[contains(@class, "gui")]');
let guiVisible = await gui.isDisplayed();
await expect(guiVisible).toBe(true);
});
test('Explore link when signed out', async () => {
await clickXpath('//li[@class="link explore"]');
let banner = await findByXpath('//h1[@class="title-banner-h1"]');
let bannerText = await banner.getText();
await expect(bannerText).toEqual('Explore');
});
test('Ideas link when signed out', async () => {
await clickXpath('//li[@class="link ideas"]');
let banner = await findByXpath('//div[contains(@class, "ideas-banner")]');
let bannerVisible = await banner.isDisplayed();
await expect(bannerVisible).toBe(true);
});
test('About link when signed out', async () => {
await clickXpath('//li[@class="link about"]');
let aboutPage = await findByXpath('//div[@class="inner about"]');
let aboutPageVisible = await aboutPage.isDisplayed();
await expect(aboutPageVisible).toBe(true);
});
test('Search Bar', async () => {
let searchBar = await findByXpath('//div[contains(@class, "search-wrapper")]/div/input');
await searchBar.sendKeys('cat');
await driver.sleep(500); // without it sends an empty string on submit
await searchBar.submit();
let banner = await findByXpath('//h1[@class="title-banner-h1"]');
let bannerText = await banner.getText();
await expect(bannerText).toEqual('Search');
});
test('Scratch Logo', async () => {
await clickXpath('//li[@class="link explore"]');
await findByXpath('//h1[@class="title-banner-h1"]');
await clickXpath('//li[@class="logo"]');
let splash = await findByXpath('//div[@class="splash"]');
let splashVisible = await splash.isDisplayed();
expect(splashVisible).toBe(true);
});
// Sign In is tested in sign-in-and-out tests
// Create Account is tested in Join tests
});

View file

@ -14,7 +14,7 @@ let projectUrl = rootUrl + '/projects/' + projectId;
if (remote){
jest.setTimeout(60000);
} else {
jest.setTimeout(10000);
jest.setTimeout(20000);
}
let driver;

View file

@ -6,7 +6,8 @@ const chromedriverVersion = require('chromedriver').version;
const headless = process.env.SMOKE_HEADLESS || false;
const remote = process.env.SMOKE_REMOTE || false;
const ci = process.env.CI || false;
const buildID = process.env.TRAVIS_BUILD_NUMBER;
const usingCircle = process.env.CIRCLECI || false;
const buildID = process.env.CIRCLE_BUILD_NUM || '0000';
const {SAUCE_USERNAME, SAUCE_ACCESS_KEY} = process.env;
const {By, Key, until} = webdriver;
@ -36,7 +37,8 @@ class SeleniumHelper {
if (remote === 'true'){
let nameToUse;
if (ci === 'true'){
nameToUse = 'travis ' + buildID + ' : ' + name;
let ciName = usingCircle ? 'circleCi ' : 'unknown ';
nameToUse = ciName + buildID + ' : ' + name;
} else {
nameToUse = name;
}

View file

@ -0,0 +1,72 @@
import React from 'react';
import {act} from 'react-dom/test-utils';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import AdminPanel from '../../../src/components/adminpanel/adminpanel.jsx';
import {
StudioAdminPanel, adminPanelOpenClass, adminPanelOpenKey
} from '../../../src/views/studio/studio-admin-panel.jsx';
let viewEl;
describe('Studio comments', () => {
beforeAll(() => {
viewEl = global.document.createElement('div');
viewEl.id = 'view';
global.document.body.appendChild(viewEl);
});
beforeEach(() => {
global.localStorage.clear();
viewEl.classList.remove(adminPanelOpenClass);
});
describe('gets stored state from local storage if available', () => {
test('stored as open', () => {
global.localStorage.setItem(adminPanelOpenKey, 'open');
const component = mountWithIntl(<StudioAdminPanel showAdminPanel />);
const child = component.find(AdminPanel);
expect(child.prop('isOpen')).toBe(true);
});
test('stored as closed', () => {
global.localStorage.setItem(adminPanelOpenKey, 'closed');
const component = mountWithIntl(<StudioAdminPanel showAdminPanel />);
const child = component.find(AdminPanel);
expect(child.prop('isOpen')).toBe(false);
});
test('not stored', () => {
const component = mountWithIntl(
<StudioAdminPanel showAdminPanel />
);
const child = component.find(AdminPanel);
expect(child.prop('isOpen')).toBe(false);
});
});
test('calling onOpen sets a class on the #viewEl and records in local storage', () => {
const component = mountWithIntl(<StudioAdminPanel showAdminPanel />);
let child = component.find(AdminPanel);
expect(viewEl.classList.contains(adminPanelOpenClass)).toBe(false);
// `act` is a test-util function for making react state updates sync
act(child.prop('onOpen'));
expect(viewEl.classList.contains(adminPanelOpenClass)).toBe(true);
expect(global.localStorage.getItem(adminPanelOpenKey)).toBe('open');
});
test('renders the correct iframe when open', () => {
global.localStorage.setItem(adminPanelOpenKey, 'open');
const component = mountWithIntl(
<StudioAdminPanel
studioId={123}
showAdminPanel
/>
);
let child = component.find('iframe');
expect(child.getDOMNode().src).toMatch('/scratch2-studios/123/adminpanel');
});
test('responds to closePanel MessageEvent from the iframe', () => {
global.localStorage.setItem(adminPanelOpenKey, 'open');
mountWithIntl(<StudioAdminPanel showAdminPanel />);
expect(viewEl.classList.contains(adminPanelOpenClass)).toBe(true);
act(() => {
global.window.dispatchEvent(new global.MessageEvent('message', {data: 'closePanel'}));
});
expect(viewEl.classList.contains(adminPanelOpenClass)).toBe(false);
expect(global.localStorage.getItem(adminPanelOpenKey)).toBe('closed');
});
});

View file

@ -7,10 +7,14 @@ describe('Studio comments', () => {
const loadComments = jest.fn();
const component = mountWithIntl(
<StudioComments
hasFetchedSession={false}
comments={[]}
handleLoadMoreComments={loadComments}
/>
);
expect(loadComments).not.toHaveBeenCalled();
component.setProps({hasFetchedSession: true});
component.update();
expect(loadComments).toHaveBeenCalled();
// When updated to have comments, load is not called again
@ -30,6 +34,7 @@ describe('Studio comments', () => {
const resetComments = jest.fn();
const component = mountWithIntl(
<StudioComments
hasFetchedSession
isAdmin={false}
comments={[{id: 123, author: {}}]}
handleResetComments={resetComments}
@ -57,6 +62,7 @@ describe('Studio comments', () => {
mountWithIntl(
<StudioComments
isAdmin
hasFetchedSession
comments={[{id: 123, author: {}}]}
handleResetComments={resetComments}
/>

View file

@ -11,10 +11,13 @@ import {
selectCanEditOpenToAll,
selectShowCuratorInvite,
selectCanInviteCurators,
selectCanRemoveCurators,
selectCanRemoveCurator,
selectCanRemoveManager,
selectCanPromoteCurators,
selectCanRemoveProject
selectCanRemoveProject,
selectShowProjectMuteError,
selectShowCuratorMuteError,
selectShowEditMuteError
} from '../../../src/redux/studio-permissions';
import {getInitialState as getInitialStudioState} from '../../../src/redux/studio';
@ -51,6 +54,21 @@ const setStateByRole = (role) => {
case 'invited':
state.studio = studios.isInvited;
break;
case 'muted creator':
state.studio = studios.creator1;
state.session = sessions.user1Muted;
break;
case 'muted manager':
state.studio = studios.isManager;
state.session = sessions.user1Muted;
break;
case 'muted curator':
state.studio = studios.isCurator;
state.session = sessions.user1Muted;
break;
case 'muted logged in':
state.session = sessions.user1Muted;
break;
default:
throw new Error('Unknown user role in test: ' + role);
}
@ -72,7 +90,9 @@ describe('studio info', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanEditInfo(state)).toBe(expected);
@ -89,7 +109,9 @@ describe('studio projects', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanAddProjects(state)).toBe(expected);
@ -100,7 +122,9 @@ describe('studio projects', () => {
test.each([
['logged in', true],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
state.studio.openToAll = true;
@ -116,7 +140,9 @@ describe('studio projects', () => {
['creator', true],
['logged in', false], // false for projects that are not theirs, see below
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveProject(state, 'not-me', 'not-me')).toBe(expected);
@ -147,7 +173,9 @@ describe('studio comments', () => {
test.each([
['logged in', true],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', true], // comment composer is there, but contains muted ComposeStatus
['muted logged in', true]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowCommentComposer(state)).toBe(expected);
@ -158,7 +186,9 @@ describe('studio comments', () => {
test.each([
['logged in', true],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', true],
['muted logged in', true]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanReportComment(state)).toBe(expected);
@ -173,7 +203,9 @@ describe('studio comments', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanDeleteComment(state)).toBe(expected);
@ -188,7 +220,9 @@ describe('studio comments', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanDeleteCommentWithoutConfirm(state)).toBe(expected);
@ -203,7 +237,9 @@ describe('studio comments', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRestoreComment(state)).toBe(expected);
@ -214,7 +250,9 @@ describe('studio comments', () => {
test.each([
['logged in', true],
['unconfirmed', true],
['logged out', false]
['logged out', false],
['muted creator', true],
['muted logged in', true]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanFollowStudio(state)).toBe(expected);
@ -229,7 +267,9 @@ describe('studio comments', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanEditCommentsAllowed(state)).toBe(expected);
@ -244,7 +284,9 @@ describe('studio comments', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanEditOpenToAll(state)).toBe(expected);
@ -262,7 +304,9 @@ describe('studio members', () => {
['invited', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowCuratorInvite(state)).toBe(expected);
@ -277,7 +321,9 @@ describe('studio members', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanPromoteCurators(state)).toBe(expected);
@ -287,15 +333,23 @@ describe('studio members', () => {
describe('can remove curators', () => {
test.each([
['admin', true],
['curator', false],
['curator', false], // except themselves, see test below
['manager', true],
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveCurators(state)).toBe(expected);
expect(selectCanRemoveCurator(state, 'others-username')).toBe(expected);
});
test('curators can remove themselves', () => {
setStateByRole('curator');
const loggedInUsername = selectUsername(state);
expect(selectCanRemoveCurator(state, loggedInUsername)).toBe(true);
});
});
@ -307,7 +361,9 @@ describe('studio members', () => {
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveManager(state, '123')).toBe(expected);
@ -321,7 +377,9 @@ describe('studio members', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
state.studio.owner = 'the creator';
@ -338,10 +396,91 @@ describe('studio members', () => {
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
['logged out', false],
['muted creator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanInviteCurators(state)).toBe(expected);
});
});
});
describe('studio mute errors', () => {
describe('should show projects mute error', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false],
['muted creator', true],
['muted manager', true],
['muted curator', true],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowProjectMuteError(state)).toBe(expected);
});
});
describe('should show projects mute error, open to all', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false],
['muted creator', true],
['muted manager', true],
['muted curator', true],
['muted logged in', true]
])('%s: %s', (role, expected) => {
setStateByRole(role);
state.studio.openToAll = true;
expect(selectShowProjectMuteError(state)).toBe(expected);
});
});
describe('should show curators mute error', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false],
// ['muted creator', true], // This one fails; not sure why
['muted manager', true],
['muted curator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowCuratorMuteError(state)).toBe(expected);
});
});
describe('should show edit info mute error', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false],
// ['muted creator', true], // This one fails; not sure why
['muted manager', true],
['muted curator', false],
['muted logged in', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowEditMuteError(state)).toBe(expected);
});
});
});