Merge branch 'develop' into muted-users-cant-edit-studios

This commit is contained in:
Paul Kaplan 2021-05-26 10:58:12 -04:00
commit cb8a869e4f
50 changed files with 1063 additions and 497 deletions

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) {
// 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 all.\n');
process.stdout.write('Purged static assets.\n');
});
}
});
}
});

85
package-lock.json generated
View file

@ -503,9 +503,9 @@
}
},
"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.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": {
@ -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",
@ -20855,9 +20856,9 @@
}
},
"scratch-blocks": {
"version": "0.1.0-prerelease.20210521033213",
"resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.20210521033213.tgz",
"integrity": "sha512-a6fmtfr3Wyr+sX9+HSxGdcUOV43fgAVYU6sDtwOWiyUiGzvzWZKUWLaf52hQOgJ5tH3Ob7OJPdkt8nvGIYU1nQ==",
"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",
@ -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.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",
@ -20918,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.20210521033213",
"scratch-l10n": "3.11.20210521031700",
"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",
@ -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.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": {
@ -21387,9 +21388,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.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",

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.20210521035500",
"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

@ -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

@ -132,6 +132,7 @@ module.exports.selectMuteStatus = state => get(state, ['session', 'session', 'pe
{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

@ -94,7 +94,11 @@ 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;
@ -115,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
}));
});
@ -170,7 +176,11 @@ module.exports = {
selectStudioImage,
selectStudioOpenToAll,
selectStudioCommentsAllowed,
selectStudioLastUpdated,
selectStudioLoadFailed,
selectStudioCommentCount,
selectStudioFollowerCount,
selectStudioProjectCount,
selectIsFetchingInfo,
selectIsFetchingRoles,
selectIsFollowing,

View file

@ -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"

View file

@ -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>

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,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,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,7 +1,9 @@
{
"studio.tabNavProjects": "Projects",
"studio.tabNavProjectsWithCount": "Projects {projectCount}",
"studio.tabNavCurators": "Curators",
"studio.tabNavComments": "Comments",
"studio.tabNavCommentsWithCount": "Comments {commentCount}",
"studio.tabNavActivity": "Activity",
"studio.title": "Title",
@ -26,7 +28,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",
@ -69,6 +75,9 @@
"studio.activityRemoveCurator": "{removerProfileLink} removed the curator {removedProfileLink}",
"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.",
@ -78,5 +87,15 @@
"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."
"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

@ -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);
});
}));

View file

@ -44,7 +44,7 @@ const normalizeError = (err, body, res) => {
const loadUserProjects = type => ((dispatch, getState) => {
const state = getState();
const projectCount = userProjects.selector(state).items.length;
const projectsPerPage = 20;
const projectsPerPage = 24;
const opts = {
...Endpoints[type](state),
params: {

View file

@ -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,
@ -72,7 +74,9 @@ const UserProjectsModal = ({
}
</SubNavigation>
<ModalInnerContent className="user-projects-modal-content">
<AlertProvider>
{error && <div>Error loading {filter}: {error}</div>}
<Alert className="studio-alert" />
<div className="user-projects-modal-grid">
{items.map(project => (
<UserProjectsTile
@ -85,6 +89,7 @@ const UserProjectsModal = ({
onRemove={onRemove}
/>
))}
</div>
{moreToLoad &&
<div className="studio-projects-load-more">
<button
@ -97,7 +102,7 @@ const UserProjectsModal = ({
</button>
</div>
}
</div>
</AlertProvider>
</ModalInnerContent>
</Modal>
);

View file

@ -31,8 +31,20 @@
& { 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 {
@ -50,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;
@ -60,6 +74,7 @@
.user-projects-modal-grid {
margin-top: 12px;
margin-bottom: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
@ -72,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,6 +28,7 @@ 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
})}
@ -43,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

@ -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,

View file

@ -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 (

View file

@ -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,7 +21,10 @@ const StudioCurators = ({
if (items.length === 0) onLoadMore();
}, []);
return (<div className="studio-members">
return (
<AlertProvider>
<div className="studio-members">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.curatorsHeader" /></h2>
</div>
@ -73,7 +78,8 @@ const StudioCurators = ({
</React.Fragment>
)}
</div>
</div>);
</div>
</AlertProvider>);
};
StudioCurators.propTypes = {

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 />
<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,7 +18,9 @@ const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
}, []);
return (
<AlertProvider>
<div className="studio-members">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.managersHeader" /></h2>
</div>
@ -47,6 +51,7 @@ const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
}
</div>
</div>
</AlertProvider>
);
};

View file

@ -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,9 +51,18 @@ const StudioMemberTile = ({
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onPromote(username).catch(e => {
setError(e);
onPromote(username)
.then(() => {
successAlert({
id: 'studio.alertManagerPromote',
values: {name: username}
});
})
.catch(() => {
errorAlert({
id: 'studio.alertManagerPromoteError',
values: {name: username}
});
setSubmitting(false);
});
}}
@ -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>
);
};

View file

@ -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

View file

@ -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>
);

View file

@ -3,6 +3,7 @@ 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, selectShowProjectMuteError} from '../../redux/studio-permissions';
@ -10,10 +11,11 @@ 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, isMuted, error,
@ -24,7 +26,9 @@ const StudioProjects = ({
}, []);
return (
<AlertProvider>
<div className="studio-projects">
<Alert className="studio-alert" />
<div className="studio-header-container">
<h2><FormattedMessage id="studio.projectsHeader" /></h2>
{canEditOpenToAll && <StudioOpenToAll />}
@ -110,6 +114,7 @@ const StudioProjects = ({
)}
</div>
</div>
</AlertProvider>
);
};

View file

@ -11,15 +11,20 @@ import {
selectors
} from '../../redux/studio-report';
import reportIcon from './icons/report-icon.svg';
const StudioReport = ({
canReport,
isOpen,
handleOpen
}) => (
<div>
{canReport && (
<button onClick={handleOpen}><FormattedMessage id="general.report" /></button>
)}
{canReport &&
<button onClick={handleOpen}>
<img src={reportIcon} />
<FormattedMessage id="general.report" />
</button>
}
{isOpen && (
<StudioReportModal />
)}

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

@ -61,6 +61,52 @@ $radius: 8px;
box-sizing: border-box;
}
}
.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: 700;
@ -115,11 +161,29 @@ $radius: 8px;
border-bottom: 1px solid $active-dark-gray;
padding-bottom: 8px;
font-size: 14px;
li { background: rgba(0, 0, 0, 0.15); }
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;
@ -199,7 +263,6 @@ $radius: 8px;
}
}
.studio-members {}
.studio-members-grid {
margin-top: 20px;
display: grid;

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

@ -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

@ -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\/?$/);
});
});

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

@ -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}
/>