mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2025-03-23 19:30:34 -04:00
Merge branch 'develop' of https://github.com/LLK/scratch-www into load-24-projects-per-page
# Conflicts: # src/views/studio/modals/user-projects-modal.jsx
This commit is contained in:
commit
a390c0655f
33 changed files with 709 additions and 365 deletions
package-lock.jsonpackage.json
src
components
alert
alert-component.jsxalert-context.jsalert-provider.jsxalert-status.jsalert.jsxalert.scssicon-alert-error.svgicon-alert-success.svg
navigation/www
subnavigation
redux
routes.jsonviews
components
studio
test
integration-legacy/smoke-testing
integration
unit/components
84
package-lock.json
generated
84
package-lock.json
generated
|
@ -1368,23 +1368,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"@formatjs/ecma402-abstract": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.9.0.tgz",
|
||||
"integrity": "sha512-hKWk3t4uKmGW1kS6lR8j3vzHhyK3oXb/sgQ6YImsHLen8FFbmPDEEiwz6geNaKtEioCGYFF1B2BYLBH8JjbFxQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
|
||||
"integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@formatjs/intl-getcanonicallocales": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.7.0.tgz",
|
||||
|
@ -1404,17 +1387,26 @@
|
|||
}
|
||||
},
|
||||
"@formatjs/intl-locale": {
|
||||
"version": "2.4.27",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-locale/-/intl-locale-2.4.27.tgz",
|
||||
"integrity": "sha512-pUq/0jXqKhFE1j19/TAgHAptrmS2J7CUlR04fov/fF/VV+J1+RMh2Uh9pkp7mZktROQ9t3YfqZIv6oyph+C1Ng==",
|
||||
"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.9.0",
|
||||
"@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.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"
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
|
||||
|
@ -1424,15 +1416,24 @@
|
|||
}
|
||||
},
|
||||
"@formatjs/intl-pluralrules": {
|
||||
"version": "4.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.21.tgz",
|
||||
"integrity": "sha512-SEouCAFL1QLVfFWCsZFNYtWXU05Q7wIaq9kGQujT25iy7eT53kvt9SVE2DySHooe56y9VBt4jS0YKPzP+BLTug==",
|
||||
"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.9.0",
|
||||
"@formatjs/ecma402-abstract": "1.9.1",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz",
|
||||
|
@ -20865,9 +20866,9 @@
|
|||
}
|
||||
},
|
||||
"scratch-gui": {
|
||||
"version": "0.1.0-prerelease.20210521035500",
|
||||
"resolved": "https://registry.npmjs.org/scratch-gui/-/scratch-gui-0.1.0-prerelease.20210521035500.tgz",
|
||||
"integrity": "sha512-wo7kKITvLVhsOJYag9LjKdeURfwdcGlDaIkkW0+uq5OioDw+7OkH5ejdE+5HKByHVuo2vbSLc3VEL5JAThlVwQ==",
|
||||
"version": "0.1.0-prerelease.20210522033750",
|
||||
"resolved": "https://registry.npmjs.org/scratch-gui/-/scratch-gui-0.1.0-prerelease.20210522033750.tgz",
|
||||
"integrity": "sha512-P32L88nVkWgnlOYZ637+wMxKor2qphP0UmOlFVMwr38UVfvovUjj+k/lp/60SX/coalCByKHdZTOuboKZcggNQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"arraybuffer-loader": "^1.0.6",
|
||||
|
@ -20919,7 +20920,7 @@
|
|||
"redux-throttle": "0.1.1",
|
||||
"scratch-audio": "0.1.0-prerelease.20200528195344",
|
||||
"scratch-blocks": "0.1.0-prerelease.20210521033213",
|
||||
"scratch-l10n": "3.11.20210521031700",
|
||||
"scratch-l10n": "3.11.20210522031558",
|
||||
"scratch-paint": "0.2.0-prerelease.20210407203313",
|
||||
"scratch-render": "0.1.0-prerelease.20210325231800",
|
||||
"scratch-render-fonts": "1.0.0-prerelease.20210401210003",
|
||||
|
@ -21088,9 +21089,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"electron-to-chromium": {
|
||||
"version": "1.3.735",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.735.tgz",
|
||||
"integrity": "sha512-cp7MWzC3NseUJV2FJFgaiesdrS+A8ZUjX5fLAxdRlcaPDkaPGFplX930S5vf84yqDp4LjuLdKouWuVOTwUfqHQ==",
|
||||
"version": "1.3.736",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.736.tgz",
|
||||
"integrity": "sha512-DY8dA7gR51MSo66DqitEQoUMQ0Z+A2DSXFi7tK304bdTVqczCAfUuyQw6Wdg8hIoo5zIxkU1L24RQtUce1Ioig==",
|
||||
"dev": true
|
||||
},
|
||||
"has-flag": {
|
||||
|
@ -21310,6 +21311,19 @@
|
|||
"integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=",
|
||||
"dev": true
|
||||
},
|
||||
"scratch-l10n": {
|
||||
"version": "3.11.20210522031558",
|
||||
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.11.20210522031558.tgz",
|
||||
"integrity": "sha512-An6Synzs0MzFnAMDaZpUL7wttZzysq0r/2MWeKO6CdU7+1AVmFDAxtCP/h5xdGzoNhxf8Vy02LvSfn4ofYZDCA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/cli": "^7.1.2",
|
||||
"@babel/core": "^7.1.2",
|
||||
"babel-plugin-react-intl": "^3.0.1",
|
||||
"react-intl": "^2.8.0",
|
||||
"transifex": "1.6.6"
|
||||
}
|
||||
},
|
||||
"scratch-storage": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-1.3.4.tgz",
|
||||
|
@ -21387,9 +21401,9 @@
|
|||
}
|
||||
},
|
||||
"scratch-l10n": {
|
||||
"version": "3.11.20210521031700",
|
||||
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.11.20210521031700.tgz",
|
||||
"integrity": "sha512-iY1xA8yZvjn0BpEOtKeiICuyaMlw7jmn5aO6P42DqWfVl9Uye34XBzeZJrMoFsUjhKZXXr7+IE2KUdiet5+7Yw==",
|
||||
"version": "3.11.20210524031609",
|
||||
"resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-3.11.20210524031609.tgz",
|
||||
"integrity": "sha512-J6fqjT9QAMACI98mk58rn48nKoQNzuP2LiEt5GgkSw+kTt/kKJTYTtT2C2hOnYJZcZrTyPFwAWTyi68sxg23xQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/cli": "^7.1.2",
|
||||
|
|
|
@ -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.20210521035500",
|
||||
"scratch-gui": "0.1.0-prerelease.20210522033750",
|
||||
"scratch-l10n": "latest",
|
||||
"selenium-webdriver": "3.6.0",
|
||||
"slick-carousel": "1.6.0",
|
||||
|
|
41
src/components/alert/alert-component.jsx
Normal file
41
src/components/alert/alert-component.jsx
Normal 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;
|
18
src/components/alert/alert-context.js
Normal file
18
src/components/alert/alert-context.js
Normal 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
|
||||
};
|
54
src/components/alert/alert-provider.jsx
Normal file
54
src/components/alert/alert-provider.jsx
Normal 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;
|
5
src/components/alert/alert-status.js
Normal file
5
src/components/alert/alert-status.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
NONE: 'NONE',
|
||||
SUCCESS: 'SUCCESS',
|
||||
ERROR: 'ERROR'
|
||||
};
|
33
src/components/alert/alert.jsx
Normal file
33
src/components/alert/alert.jsx
Normal 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;
|
40
src/components/alert/alert.scss
Normal file
40
src/components/alert/alert.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
3
src/components/alert/icon-alert-error.svg
Normal file
3
src/components/alert/icon-alert-error.svg
Normal 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 (image error) Size: 559 B |
9
src/components/alert/icon-alert-success.svg
Normal file
9
src/components/alert/icon-alert-success.svg
Normal 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 (image error) Size: 1.1 KiB |
|
@ -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"
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
flex-wrap: wrap;
|
||||
|
||||
|
||||
li {
|
||||
li, button {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
border: 1px solid $active-gray;
|
||||
|
|
|
@ -128,6 +128,7 @@ module.exports.selectToken = state => get(state, ['session', 'session', 'user',
|
|||
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.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);
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
"name": "conference-index",
|
||||
"pattern": "^/conference/?(\\?.*)?$",
|
||||
"routeAlias": "/conference(?!/201[4-9])",
|
||||
"redirect": "/conference/2020"
|
||||
"redirect": "/conference/2021"
|
||||
},
|
||||
{
|
||||
"name": "conference-index-2017",
|
||||
|
@ -83,6 +83,12 @@
|
|||
"name": "conference-index-2020",
|
||||
"pattern": "^/conference/2020/?$",
|
||||
"routeAlias": "/conference(?!/201[4-9])",
|
||||
"redirect": "/conference/2021"
|
||||
},
|
||||
{
|
||||
"name": "conference-index-2021",
|
||||
"pattern": "^/conference/2021/?$",
|
||||
"routeAlias": "/conference(?!/201[4-9])",
|
||||
"view": "conference/2020/index/index",
|
||||
"title": "Scratch Conferences",
|
||||
"viewportWidth": "device-width"
|
||||
|
|
|
@ -14,12 +14,43 @@ 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>
|
||||
|
|
|
@ -26,7 +26,11 @@
|
|||
"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 add project. Check the URL and try again.",
|
||||
"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",
|
||||
|
||||
|
@ -73,5 +77,15 @@
|
|||
"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."
|
||||
"studio.reportYourFeedback": "Your feedback will help us make Scratch better.",
|
||||
|
||||
"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}\""
|
||||
}
|
||||
|
|
|
@ -121,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);
|
||||
});
|
||||
}));
|
||||
|
|
|
@ -18,6 +18,8 @@ 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, showStudentsFilter,
|
||||
|
@ -44,60 +46,64 @@ 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 &&
|
||||
<li
|
||||
<button
|
||||
className={classNames({active: filter === Filters.STUDENTS})}
|
||||
onClick={() => setFilter(Filters.STUDENTS)}
|
||||
>
|
||||
<FormattedMessage id="studio.studentsFilter" />
|
||||
</li>
|
||||
</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>
|
||||
{moreToLoad &&
|
||||
<div className="studio-projects-load-more">
|
||||
<button
|
||||
className={classNames('button', {
|
||||
'mod-mutating': loading
|
||||
})}
|
||||
onClick={() => onLoadMore(filter)}
|
||||
>
|
||||
<FormattedMessage id="general.loadMore" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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 (
|
||||
|
@ -52,7 +55,6 @@ const UserProjectsTile = ({id, title, image, inStudio, onAdd, onRemove}) => {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
{error && <div>{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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.
|
||||
|
@ -108,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,
|
||||
|
@ -133,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,
|
||||
|
|
|
@ -5,6 +5,7 @@ import {connect} from 'react-redux';
|
|||
import classNames from 'classnames';
|
||||
import {FormattedMessage, intlShape, injectIntl} from 'react-intl';
|
||||
|
||||
import {useAlertContext} from '../../components/alert/alert-context';
|
||||
import {Errors, inviteCurator} from './lib/studio-member-actions';
|
||||
import ValidationMessage from '../../components/forms/validation-message.jsx';
|
||||
|
||||
|
@ -24,12 +25,30 @@ 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 (
|
||||
|
|
|
@ -11,6 +11,8 @@ 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 AlertProvider from '../../components/alert/alert-provider.jsx';
|
||||
import Alert from '../../components/alert/alert.jsx';
|
||||
|
||||
const StudioCurators = ({
|
||||
canInviteCurators, showCuratorInvite, items, error, loading, moreToLoad, onLoadMore
|
||||
|
@ -19,61 +21,65 @@ const StudioCurators = ({
|
|||
if (items.length === 0) onLoadMore();
|
||||
}, []);
|
||||
|
||||
return (<div className="studio-members">
|
||||
<div className="studio-header-container">
|
||||
<h2><FormattedMessage id="studio.curatorsHeader" /></h2>
|
||||
</div>
|
||||
{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 />}
|
||||
{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>
|
||||
</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 = {
|
||||
|
|
|
@ -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,24 +18,26 @@ const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="studio-members">
|
||||
<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 &&
|
||||
<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', {
|
||||
|
@ -44,9 +48,10 @@ const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
|
|||
<FormattedMessage id="general.loadMore" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
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';
|
||||
|
@ -23,7 +24,7 @@ const StudioMemberTile = ({
|
|||
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">
|
||||
|
@ -50,11 +51,20 @@ const StudioMemberTile = ({
|
|||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
onPromote(username).catch(e => {
|
||||
setError(e);
|
||||
setSubmitting(false);
|
||||
});
|
||||
onPromote(username)
|
||||
.then(() => {
|
||||
successAlert({
|
||||
id: 'studio.alertManagerPromote',
|
||||
values: {name: username}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
errorAlert({
|
||||
id: 'studio.alertManagerPromoteError',
|
||||
values: {name: username}
|
||||
});
|
||||
setSubmitting(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img src={promoteIcon} />
|
||||
|
@ -69,9 +79,11 @@ const StudioMemberTile = ({
|
|||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
onRemove(username).catch(e => {
|
||||
setError(e);
|
||||
onRemove(username).catch(() => {
|
||||
errorAlert({
|
||||
id: 'studio.alertMemberRemoveError',
|
||||
values: {name: username}
|
||||
}, null);
|
||||
setSubmitting(false);
|
||||
});
|
||||
}}
|
||||
|
@ -82,7 +94,6 @@ const StudioMemberTile = ({
|
|||
</li>}
|
||||
</OverflowMenu>
|
||||
}
|
||||
{error && <div>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,21 +5,50 @@ import {connect} from 'react-redux';
|
|||
import classNames from 'classnames';
|
||||
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 ValidationMessage from '../../components/forms/validation-message.jsx';
|
||||
import {useAlertContext} from '../../components/alert/alert-context';
|
||||
|
||||
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 (
|
||||
|
@ -30,7 +59,7 @@ const StudioProjectAdder = ({intl, onSubmit}) => {
|
|||
<ValidationMessage
|
||||
mode="error"
|
||||
className="validation-left"
|
||||
message={<FormattedMessage id="studio.projectErrors.checkUrl" />}
|
||||
message={<FormattedMessage id={errorToMessageId(error)} />}
|
||||
/>
|
||||
</div>}
|
||||
<input
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
/* 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';
|
||||
|
||||
|
@ -16,9 +17,9 @@ const StudioProjectTile = ({
|
|||
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}>
|
||||
|
@ -54,11 +55,10 @@ const StudioProjectTile = ({
|
|||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
onRemove(id)
|
||||
.catch(e => {
|
||||
setError(e);
|
||||
.catch(() => {
|
||||
setSubmitting(false);
|
||||
errorAlert({id: 'studio.alertProjectRemoveError'}, null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -67,7 +67,6 @@ const StudioProjectTile = ({
|
|||
</button></li>
|
||||
</OverflowMenu>
|
||||
}
|
||||
{error && <div>{error}</div>} {/* TODO where do these errors go? */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -11,6 +11,8 @@ 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 AlertProvider from '../../components/alert/alert-provider.jsx';
|
||||
import Alert from '../../components/alert/alert.jsx';
|
||||
|
||||
const StudioProjects = ({
|
||||
canAddProjects, canEditOpenToAll, items, error, loading, moreToLoad, onLoadMore
|
||||
|
@ -20,62 +22,64 @@ const StudioProjects = ({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="studio-projects">
|
||||
<div className="studio-header-container">
|
||||
<h2><FormattedMessage id="studio.projectsHeader" /></h2>
|
||||
{canEditOpenToAll && <StudioOpenToAll />}
|
||||
</div>
|
||||
{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>
|
||||
<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 &&
|
||||
<AlertProvider>
|
||||
<div className="studio-projects">
|
||||
<Alert className="studio-alert" />
|
||||
<div className="studio-header-container">
|
||||
<h2><FormattedMessage id="studio.projectsHeader" /></h2>
|
||||
{canEditOpenToAll && <StudioOpenToAll />}
|
||||
</div>
|
||||
{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>
|
||||
<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', {
|
||||
|
@ -86,11 +90,12 @@ const StudioProjects = ({
|
|||
<FormattedMessage id="general.loadMore" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</React.Fragment>
|
||||
)}
|
||||
}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -113,7 +113,10 @@ $radius: 8px;
|
|||
.active > li { background: $ui-blue; }
|
||||
}
|
||||
|
||||
.studio-projects {}
|
||||
.studio-projects, .studio-members {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.studio-projects-grid {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
|
@ -193,7 +196,6 @@ $radius: 8px;
|
|||
}
|
||||
}
|
||||
|
||||
.studio-members {}
|
||||
.studio-members-grid {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -139,7 +139,7 @@ describe('www-integration footer links', () => {
|
|||
await clickText('Scratch Conference');
|
||||
let url = await driver.getCurrentUrl();
|
||||
let pathname = (new URL(url)).pathname;
|
||||
expect(pathname).toMatch(/^\/conference\/2020\/?$/);
|
||||
expect(pathname).toMatch(/^\/conference\/2021\/?$/);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
106
test/integration/navbar.test.js
Normal file
106
test/integration/navbar.test.js
Normal 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
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
|
|
Loading…
Add table
Reference in a new issue