Unverified Commit d2028869 authored by picklesrus's avatar picklesrus Committed by GitHub

Merge pull request #5185 from LLK/release/2021-03-24

Release/2021 03 24
parents 92494b52 b3022d69
......@@ -3,7 +3,7 @@ version: 2.1
aliases:
- &defaults
docker:
- image: circleci/node:12-browsers
- image: cimg/node:12.20.1-browsers
auth:
username: $DOCKERHUB_USERNAME
password: $DOCKERHUB_PASSWORD
......@@ -27,7 +27,6 @@ aliases:
restore_cache:
keys:
- v1-npm-{{ checksum "package-lock.json" }}
- v1-npm-
- &save_build_cache
save_cache:
paths:
......@@ -45,15 +44,33 @@ aliases:
steps:
- *restore_git_cache
- checkout
- *restore_npm_cache
- run:
name: "Run npm test to build"
name: "setup"
command: |
npm --production=false ci
mkdir ./test/results
- run:
name: "run lint tests"
command: |
npm run test:lint:ci
- run:
name: "run npm build"
command: |
npm --production=false install
WWW_VERSION=${CIRCLE_SHA1:0:5} npm run test
WWW_VERSION=${CIRCLE_SHA1:0:5} npm run build
- run:
name: "Run unit tests"
command: |
JEST_JUNIT_OUTPUT_NAME=unit-jest-results.xml npm run test:unit:jest:unit -- --reporters=jest-junit
JEST_JUNIT_OUTPUT_NAME=localization-jest-results.xml npm run test:unit:jest:localization -- --reporters=jest-junit
npm run test:unit:tap -- --output-file ./test/results/unit-raw.tap
npm run test:unit:convertReportToXunit
- *save_npm_cache
- *save_git_cache
- *save_build_cache
- store_test_results:
path: test/results
- store_artifacts:
path: build
- &deploy
<<: *defaults
steps:
......@@ -61,41 +78,63 @@ aliases:
- checkout
- *restore_npm_cache
- *restore_build_cache
- run:
name: "setup python"
command: |
curl https://bootstrap.pypa.io/3.5/get-pip.py -o get-pip.py
python3 get-pip.py pip==21.0.1
pip install s3cmd==2.1.0
- run:
name: "deploy to staging"
command: |
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
python get-pip.py
pip install -r requirements.txt
npm run deploy
- &integration
- &integration_jest
<<: *defaults
steps:
- *restore_git_cache
- checkout
- *restore_npm_cache
- run:
name: "integration tests"
name: "integration tests with Jest"
command: |
npm run test:integration:remote
JEST_JUNIT_OUTPUT_NAME=integration-jest-results.xml npm run test:integration:jest:remote -- --reporters=jest-junit
- store_test_results:
path: test/results
- &integration_tap
<<: *defaults
steps:
- *restore_git_cache
- checkout
- *restore_npm_cache
- run:
name: "integration tests with Tap"
command: |
mkdir ./test/results
npm run test:smoke:sauce -- --output-file ./test/results/integration-raw-tap.tap
npm run test:smoke:convertReportToXunit
- store_test_results:
path: test/results
jobs:
build-staging:
<<: *build
build-production:
# <<: *build
<<: *build
deploy-staging:
<<: *deploy
deploy-production:
# <<: *deploy
integration-staging:
<<: *integration
integration-production:
# <<: *integration
integration-staging-jest:
<<: *integration_jest
integration-staging-tap:
<<: *integration_tap
integration-production-jest:
# <<: *integration_jest
integration-production-tap:
# <<: *integration_tap
workflows:
build-test-deploy:
build-staging-production: # build-test-deploy
jobs:
- build-staging:
context:
......@@ -107,6 +146,14 @@ workflows:
- develop
- /^hotfix\/.*/
- /^release\/.*/
- build-production:
context:
- scratch-www-all
- scratch-www-production
filters:
branches:
only:
- master
# - deploy-staging:
# context:
# - scratch-www-all
......@@ -119,7 +166,21 @@ workflows:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - integration-staging:
# - circleCI-configure-tests
# - integration-staging-jest:
# context:
# - scratch-www-all
# - scratch-www-staging
# requires:
# - deploy-staging
# filters:
# branches:
# only:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - circleCI-configure-tests
# - integration-staging-tap:
# context:
# - scratch-www-all
# - scratch-www-staging
......@@ -131,3 +192,4 @@ workflows:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - circleCI-configure-tests
......@@ -20,6 +20,7 @@ deploy.zip
ENV
# Test
/test/results/*
/.nyc_output
/coverage
/bin/lib/localized-urls.json
......
This diff is collapsed.
......@@ -6,16 +6,21 @@
"start": "node ./dev-server/index.js",
"test": "npm run test:lint && npm run build && npm run test:unit",
"test:lint": "eslint . --ext .js,.jsx,.json",
"test:lint:ci": "eslint . --ext .js,.jsx,.json --format junit -o ./test/results/lint-results.xml",
"test:integration": "npm run test:integration:jest && npm run test:smoke",
"test:integration:jest": "jest ./test/integration/*.test.js",
"test:integration:jest": "jest ./test/integration/*.test.js --reporters=default",
"test:integration:remote": "npm run test:integration:jest:remote && npm run test:smoke:sauce",
"test:integration:jest:remote": "SMOKE_REMOTE=true jest ./test/integration/*.test.js",
"test:integration:jest:remote": "SMOKE_REMOTE=true jest ./test/integration/*.test.js --reporters=default",
"test:smoke": "tap ./test/integration-legacy/smoke-testing/*.js --timeout=3600 --no-coverage -R classic",
"test:smoke:verbose": "tap ./test/integration-legacy/smoke-testing/*.js --timeout=3600 --no-coverage -R spec",
"test:smoke:sauce": "SMOKE_REMOTE=true tap ./test/integration-legacy/smoke-testing/*.js --timeout=60000 --no-coverage -R classic",
"test:smoke:convertReportToXunit": "tap ./test/results/integration-raw-tap.tap --no-coverage -R xunit > ./test/results/integration-tap-results.xml",
"test:unit": "npm run test:unit:jest && npm run test:unit:tap",
"test:unit:jest": "jest ./test/unit/ && jest ./test/localization/*.test.js",
"test:unit:jest": "npm run test:unit:jest:unit && npm run test:unit:jest:localization",
"test:unit:jest:unit": "jest ./test/unit/ --reporters=default",
"test:unit:jest:localization": "jest ./test/localization/*.test.js --reporters=default",
"test:unit:tap": "tap ./test/{unit-legacy,localization-legacy}/*.js --no-coverage -R classic",
"test:unit:convertReportToXunit": "tap ./test/results/unit-raw.tap --no-coverage -R xunit > ./test/results/unit-tap-results.xml",
"test:coverage": "tap ./test/{unit-legacy,localization-legacy}/*.js --coverage --coverage-report=lcov",
"build": "npm run clean && npm run translate && NODE_OPTIONS=--max_old_space_size=8000 webpack --bail",
"clean": "rm -rf ./build && rm -rf ./intl && mkdir -p build && mkdir -p intl",
......@@ -77,7 +82,7 @@
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.14.0",
"eslint": "5.16.0",
"eslint-config-scratch": "6.0.0",
"eslint-config-scratch": "7.0.0",
"eslint-plugin-json": "2.0.1",
"eslint-plugin-react": "7.14.2",
"fastly": "1.2.1",
......@@ -90,6 +95,7 @@
"html-webpack-plugin": "^3.2.0",
"iso-3166-2": "0.4.0",
"jest": "^23.6.0",
"jest-junit": "12.0.0",
"keymirror": "0.1.1",
"lodash.bindall": "4.4.0",
"lodash.defaultsdeep": "4.6.1",
......@@ -119,12 +125,12 @@
"redux-mock-store": "^1.2.3",
"redux-thunk": "2.0.1",
"sass-loader": "6.0.6",
"scratch-gui": "0.1.0-prerelease.20210317035743",
"scratch-gui": "0.1.0-prerelease.20210324120840",
"scratch-l10n": "latest",
"selenium-webdriver": "3.6.0",
"slick-carousel": "1.6.0",
"style-loader": "0.12.3",
"tap": "14.10.8",
"tap": "14.11.0",
"url-loader": "2.3.0",
"webpack": "^4.46.0",
"webpack-bundle-analyzer": "^4.4.0",
......@@ -139,7 +145,14 @@
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/test/__mocks__/fileMock.js",
"\\.(css|less|scss)$": "<rootDir>/test/__mocks__/styleMock.js"
}
},
"reporters": [
"default",
"jest-junit"
]
},
"jest-junit": {
"outputDirectory": "./test/results"
},
"nyc": {
"include": [
......
......@@ -4,47 +4,53 @@
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 2,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 3,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 4,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 5,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 6,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
}
]
......@@ -327,8 +327,6 @@
"comments.isBad": "Hmm...the bad word detector thinks there is a problem with your comment. Please change it and remember to be respectful.",
"comments.hasChatSite": "Uh oh! The comment contains a link to a website with unmoderated chat. For safety reasons, please do not link to these sites!",
"comments.isSpam": "Hmm, seems like you've posted the same comment a bunch of times. Please don't spam.",
"comments.isMuted": "Hmm, the filterbot is pretty sure your recent comments weren't ok for Scratch, so your account has been muted for the rest of the day. :/",
"comments.isUnconstructive": "Hmm, the filterbot thinks your comment may be mean or disrespectful. Remember, most projects on Scratch are made by people who are just learning how to program.",
"comments.isDisallowed": "Hmm, it looks like comments have been turned off for this page. :/",
"comments.isIPMuted": "Sorry, the Scratch Team had to prevent your network from sharing comments or projects because it was used to break our community guidelines too many times. You can still share comments and projects from another network. If you'd like to appeal this block, you can contact appeals@scratch.mit.edu and reference Case Number {appealId}.",
"comments.isTooLong": "That comment is too long! Please find a way to shorten your text.",
......
/**
* @typedef ReduxModule
* A redux "module" for reusable functionality. The module exports
* a reducer function, a set of action creators and a selector
* that are all scoped to the given "key". This allows us to reuse
* this reducer multiple times in the same redux store.
*
* @property {string} key The key to use when registering this
* modules reducer in the redux state tree.
* @property {function} selector Function called with the full
* state tree to select only this modules slice of the state.
* @property {object} actions An object of action creator functions
* to call to make changes to the data in this reducer.
* @property {function} reducer A redux reducer that takes an action
* from the action creators and the current state and returns
* the next state.
*/
/**
* @typedef {function} InfiniteListFetcher
* A function to call that returns more data for the InfiniteList
* loadMore action. It must resolve to {items: [], moreToLoad} or
* reject with the error {statusCode}.
* @returns {Promise<{items:[], moreToLoad:boolean}>}
*/
/**
* A redux module to create a list of items where more items can be loaded
* using an API. Additionally, there are actions for prepending items
* to the list, removing items and handling load errors.
*
* @param {string} key - used to scope action names and the selector
* This key must be unique among other instances of this module.
* @returns {ReduxModule} the redux module
*/
const InfiniteList = key => {
const initialState = {
items: [],
offset: 0,
error: null,
loading: true,
moreToLoad: false
};
const reducer = (state, action) => {
if (typeof state === 'undefined') {
state = initialState;
}
switch (action.type) {
case `${key}_LOADING`:
return {
...state,
error: null,
loading: true
};
case `${key}_APPEND`:
return {
...state,
items: state.items.concat(action.items),
loading: false,
error: null,
moreToLoad: action.moreToLoad
};
case `${key}_REPLACE`:
return {
...state,
items: state.items.map((item, i) => {
if (i === action.index) return action.item;
return item;
})
};
case `${key}_REMOVE`:
return {
...state,
items: state.items.filter((_, i) => i !== action.index)
};
case `${key}_PREPEND`:
return {
...state,
items: [action.item].concat(state.items)
};
case `${key}_ERROR`:
return {
...state,
error: action.error,
loading: false,
moreToLoad: false
};
default:
return state;
}
};
const actions = {
create: item => ({type: `${key}_PREPEND`, item}),
remove: index => ({type: `${key}_REMOVE`, index}),
replace: (index, item) => ({type: `${key}_REPLACE`, index, item}),
error: error => ({type: `${key}_ERROR`, error}),
loading: () => ({type: `${key}_LOADING`}),
append: (items, moreToLoad) => ({type: `${key}_APPEND`, items, moreToLoad}),
/**
* Load more action returns a thunk. It takes a function to call to get more items.
* It will call the LOADING action before calling the fetcher, and call
* APPEND with the results or call ERROR.
* @param {InfiniteListFetcher} fetcher - function that returns a promise
* which must resolve to {items: [], moreToLoad}.
* @returns {function} a thunk that sequences the load and dispatches
*/
loadMore: fetcher => (dispatch => {
dispatch(actions.loading());
return fetcher()
.then(({items, moreToLoad}) => dispatch(actions.append(items, moreToLoad)))
.catch(error => dispatch(actions.error(error)));
})
};
const selector = state => state[key];
return {
key, actions, reducer, selector
};
};
export default InfiniteList;
const keyMirror = require('keymirror');
const api = require('../lib/api');
const log = require('../lib/log');
const Status = keyMirror({
FETCHED: null,
NOT_FETCHED: null,
FETCHING: null,
ERROR: null
});
const getInitialState = () => ({
infoStatus: Status.NOT_FETCHED,
title: '',
description: '',
openToAll: false,
commentingAllowed: false,
thumbnail: '',
followers: 0,
rolesStatus: Status.NOT_FETCHED,
manager: false,
curator: false,
follower: false,
invited: false
});
const studioReducer = (state, action) => {
if (typeof state === 'undefined') {
state = getInitialState();
}
switch (action.type) {
case 'SET_INFO':
return {
...state,
...action.info
};
case 'SET_ROLES':
return {
...state,
...action.roles
};
case 'SET_FETCH_STATUS':
if (action.error) {
log.error(action.error);
}
return {
...state,
[action.fetchType]: action.fetchStatus
};
default:
return state;
}
};
const setFetchStatus = (fetchType, fetchStatus, error) => ({
type: 'SET_FETCH_STATUS',
fetchType,
fetchStatus,
error
});
const setInfo = info => ({
type: 'SET_INFO',
info: info
});
const setRoles = roles => ({
type: 'SET_ROLES',
roles: roles
});
const getInfo = studioId => (dispatch => {
dispatch(setFetchStatus('infoStatus', Status.FETCHING));
api({uri: `/studios/${studioId}`}, (err, body, res) => {
if (err || typeof body === 'undefined' || res.statusCode !== 200) {
dispatch(setFetchStatus('infoStatus', Status.ERROR, err));
return;
}
dispatch(setFetchStatus('infoStatus', Status.FETCHED));
dispatch(setInfo({
title: body.title,
description: body.description,
openToAll: body.open_to_all,
commentingAllowed: body.commenting_allowed,
updated: new Date(body.history.modified),
followers: body.stats.followers
}));
});
});
const getRoles = (studioId, username, token) => (dispatch => {
dispatch(setFetchStatus('rolesStatus', Status.FETCHING));
api({
uri: `/studios/${studioId}/users/${username}`,
authentication: token
}, (err, body, res) => {
if (err || typeof body === 'undefined' || res.statusCode !== 200) {
dispatch(setFetchStatus('rolesStatus', Status.ERROR, err));
return;
}
dispatch(setFetchStatus('rolesStatus', Status.FETCHED));
dispatch(setRoles({
manager: body.manager,
curator: body.curator,
following: body.following,
invited: body.invited
}));
});
});
module.exports = {
getInitialState,
studioReducer,
Status,
getInfo,
getRoles
};
......@@ -5,6 +5,7 @@ const Page = require('../../components/page/www/page.jsx');
const Box = require('../../components/box/box.jsx');
const Button = require('../../components/forms/button.jsx');
const Carousel = require('../../components/carousel/carousel.jsx');
const Form = require('../../components/forms/form.jsx');
const Input = require('../../components/forms/input.jsx');
const Spinner = require('../../components/spinner/spinner.jsx');
......@@ -16,11 +17,13 @@ const Components = () => (
<h1>Button</h1>
<Button>I love button</Button>
<h1>Form</h1>
<Input
maxLength="30"
name="test"
type="text"
/>
<Form>
<Input
maxLength="30"
name="test"
type="text"
/>
</Form>
<h1>Box Component</h1>
<Box
more="Cat Gifs"
......
......@@ -68,7 +68,7 @@ const ConferenceSplash = () => (
</table>
<a
className="button mod-2020-panel"
href="http://scratch2020.eventbrite.com/"
href="http://scratch2021.eventbrite.com/"
>
<FormattedMessage id="conference-2020.register" />
</a>
......
{
"conference-2020.title": "Scratch Around the World:",
"conference-2020.subtitle": "An Online Conference",
"conference-2020.dateDesc": "July 22",
"conference-2020.dateDesc": "July 22, 2021",
"conference-2020.locationDetails": "Online",
"conference-2020.date": "When:",
......
......@@ -47,22 +47,27 @@
"project.usernameBlockAlert": "This project can detect who is using it, through the \"username\" block. To hide your identity, sign out before using the project.",
"project.inappropriateUpdate": "Hmm...the bad word detector thinks there is a problem with your text. Please change it and remember to be respectful.",
"comment.type.general": "It appears that your most recent comment didn't follow the Scratch Community Guidelines.",
"comment.type.general.past": "It appears that one of your recent comments didn’t follow the Scratch Community Guidelines.",
"comment.general.header": "We encourage you to post comments that follow the Scratch Community Guidelines.",
"comment.general.content1": "On Scratch, it's important for comments to be kind, to be appropriate for all ages, and to not contain spam.",
"comment.type.pii": "Your most recent comment appeared to be sharing or asking for private information.",
"comment.type.pii.past": "It appears that one of your recent comments was sharing or asking for private information.",
"comment.pii.header": "Please be sure not to share private information on Scratch.",
"comment.pii.content1": "It appears that you were sharing or asking for private information.",
"comment.pii.content2": "Things you share on Scratch can be seen by everyone, and can appear in search engines. Private information can be used by other people in harmful ways, so it’s important to keep it private.",
"comment.pii.content3": "This is a serious safety issue.",
"comment.type.unconstructive": "It appears that your most recent comment was saying something that might have been hurtful.",
"comment.type.unconstructive.past": "It appears that one of your recent comments was saying something that might have been hurtful.",
"comment.unconstructive.header": "We encourage you to be supportive when commenting on other people’s projects",
"comment.unconstructive.content1": "It appears that your comment was saying something that might have been hurtful.",
"comment.unconstructive.content2": "If you think something could be better, you can say something you like about the project, and make a suggestion about how to improve it.",
"comment.type.vulgarity": "Your most recent comment appeared to include a bad word.",
"comment.type.vulgarity.past": "It appears that one of your recent comments contained a bad word.",
"comment.vulgarity.header": "We encourage you to use language that’s appropriate for all ages.",
"comment.vulgarity.content1": "It appears that your comment contains a bad word.",
"comment.vulgarity.content2": "Scratch has users of all ages, so it’s important to use language that is appropriate for all Scratchers.",
"comment.type.spam": "Your most recent comment appeared to contain advertising, text art, or a chain message.",
"comment.type.spam.past": "It appears that one of your recent comments contained advertising, text art, or a chain message.",
"comment.spam.header": "We encourage you not to advertise, copy and paste text art, or ask others to copy comments.",
"comment.spam.content1": "Even though advertisements, text art, and chain mail can be fun, they start to fill up the website, and we want to make sure there is room for other comments.",
"comment.spam.content2": "Thank you for helping us keep Scratch a friendly, creative community!"
......
import React from 'react';
import PropTypes from 'prop-types';
const Debug = ({label, data}) => (<div style={{padding: '2rem', border: '1px solid red', margin: '2rem'}}>
<small>{label}</small>
<code>
<pre style={{fontSize: '0.75rem'}}>
{JSON.stringify(data, null, ' ')}
</pre>
</code>
</div>);
Debug.propTypes = {
label: PropTypes.string,
data: PropTypes.any // eslint-disable-line react/forbid-prop-types
};
export default Debug;
const ITEM_LIMIT = 4;
const projectFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/projects?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
const curatorFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/curators?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
const managerFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/managers?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
const activityFetcher = studioId =>
fetch(`${process.env.API_HOST}/studios/${studioId}/activity`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: false})); // No pagination on the activity feed
export {
activityFetcher,
projectFetcher,
curatorFetcher,
managerFetcher
};
import InfiniteList from '../../../redux/infinite-list';
const projects = InfiniteList('projects');
const curators = InfiniteList('curators');
const managers = InfiniteList('managers');
const activity = InfiniteList('activity');
export {
projects, curators, managers, activity
};
import React from 'react';
import {useParams} from 'react-router-dom';
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
const StudioActivity = () => {
import {connect} from 'react-redux';
import {useParams} from 'react-router';
import {activity} from './lib/redux-modules';
import {activityFetcher} from './lib/fetchers';
import Debug from './debug.jsx';
const StudioActivity = ({items, loading, error, onInitialLoad}) => {
const {studioId} = useParams();
// Fetch the data if none has been loaded yet. This would run only once,
// since studioId doesnt change, but the component is potentially mounted
// multiple times because of tab routing, so need to check for empty items.
useEffect(() => {
if (studioId && items.length === 0) onInitialLoad(studioId);
}, [studioId]); // items.length intentionally left out
return (
<div>
<h2>Activity</h2>
<p>Studio {studioId}</p>
{loading && <div>Loading...</div>}
{error && <Debug
label="Error"
data={error}
/>}
<div>
{items.map((item, index) =>
(<Debug
label="Activity Item"
data={item}
key={index}
/>)
)}
</div>
</div>
);
};
export default StudioActivity;
StudioActivity.propTypes = {
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onInitialLoad: PropTypes.func
};
export default connect(
state => activity.selector(state),
dispatch => ({
onInitialLoad: studioId => dispatch(
activity.actions.loadMore(activityFetcher.bind(null, studioId, 0)))
})
)(StudioActivity);
import React from 'react';
import React, {useEffect, useCallback} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
import {curators, managers} from './lib/redux-modules';
import {curatorFetcher, managerFetcher} from './lib/fetchers';
import Debug from './debug.jsx';
const StudioCurators = () => {
const {studioId} = useParams();
return (
<div>
<h2>Curators</h2>
<p>Studio {studioId}</p>
<h3>Managers</h3>
<ManagerList studioId={studioId} />
<hr />
<h3>Curators</h3>
<CuratorList studioId={studioId} />
</div>
);
};
const MemberList = ({studioId, items, error, loading, moreToLoad, onLoadMore}) => {
useEffect(() => {
if (studioId && items.length === 0) onLoadMore(studioId, 0);
}, [studioId]);
const handleLoadMore = useCallback(() => onLoadMore(studioId, items.length), [studioId, items.length]);
return (<React.Fragment>
{error && <Debug
label="Error"
data={error}
/>}
{items.map((item, index) =>
(<Debug
label="Member"
data={item}
key={index}
/>)
)}
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={handleLoadMore}>
Load more
</button> :
<small>No more to load</small>
)}
</React.Fragment>);
};
MemberList.propTypes = {
studioId: PropTypes.string,
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
};
const ManagerList = connect(
state => managers.selector(state),
dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
managers.actions.loadMore(managerFetcher.bind(null, studioId, offset)))
})
)(MemberList);
const CuratorList = connect(
state => curators.selector(state),
dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
curators.actions.loadMore(curatorFetcher.bind(null, studioId, offset)))
})
)(MemberList);
export default StudioCurators;
import React from 'react';
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
import Debug from './debug.jsx';
import {getInfo, getRoles} from '../../redux/studio';
const StudioInfo = () => {
const StudioInfo = ({username, studio, token, onLoadInfo, onLoadRoles}) => {
const {studioId} = useParams();
useEffect(() => { // Load studio info after first render
if (studioId) onLoadInfo(studioId);
}, [studioId]);
useEffect(() => { // Load roles info once the username is available
if (studioId && username && token) onLoadRoles(studioId, username, token);
}, [studioId, username, token]);
return (
<div>
<h2>Studio Info</h2>
<p>Studio {studioId}</p>
<Debug
label="Studio Info"
data={studio}
/>
</div>
);
};
export default StudioInfo;
StudioInfo.propTypes = {
username: PropTypes.string,
token: PropTypes.string,
studio: PropTypes.shape({
// Fill this in as the data is used, just for demo now
}),
onLoadInfo: PropTypes.func,
onLoadRoles: PropTypes.func
};
export default connect(
state => {
const user = state.session.session.user;
return {
studio: state.studio,
username: user && user.username,
token: user && user.token
};
},
dispatch => ({
onLoadInfo: studioId => dispatch(getInfo(studioId)),
onLoadRoles: (studioId, username, token) => dispatch(
getRoles(studioId, username, token))
})
)(StudioInfo);
import React from 'react';
import React, {useEffect, useCallback} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
const StudioProjects = () => {
import {projectFetcher} from './lib/fetchers';
import {projects} from './lib/redux-modules';
import Debug from './debug.jsx';
const {actions, selector} = projects;
const StudioProjects = ({
items, error, loading, moreToLoad, onLoadMore
}) => {
const {studioId} = useParams();
useEffect(() => {
if (studioId && items.length === 0) onLoadMore(studioId, 0);
}, [studioId]);
const handleLoadMore = useCallback(() => onLoadMore(studioId, items.length), [studioId, items.length]);
return (
<div>
<h2>Projects</h2>
<p>Studio {studioId}</p>
{error && <Debug
label="Error"
data={error}
/>}
<div>
{items.map((item, index) =>
(<Debug
label="Project"
data={item}
key={index}
/>)
)}
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={handleLoadMore}>
Load more
</button> :
<small>No more to load</small>
)}
</div>
</div>
);
};
export default StudioProjects;
StudioProjects.propTypes = {
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
};
const mapStateToProps = state => selector(state);
const mapDispatchToProps = dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
actions.loadMore(projectFetcher.bind(null, studioId, offset))
)
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioProjects);
......@@ -9,6 +9,7 @@ import {
import Page from '../../components/page/www/page.jsx';
import render from '../../lib/render.jsx';
import StudioTabNav from './studio-tab-nav.jsx';
import StudioProjects from './studio-projects.jsx';
import StudioInfo from './studio-info.jsx';
......@@ -16,6 +17,15 @@ import StudioCurators from './studio-curators.jsx';
import StudioComments from './studio-comments.jsx';
import StudioActivity from './studio-activity.jsx';
import {
projects,
curators,
managers,
activity
} from './lib/redux-modules';
const {studioReducer} = require('../../redux/studio');
const StudioShell = () => {
const match = useRouteMatch();
......@@ -59,5 +69,12 @@ render(
</Switch>
</Router>
</Page>,
document.getElementById('app')
document.getElementById('app'),
{
[projects.key]: projects.reducer,
[curators.key]: curators.reducer,
[managers.key]: managers.reducer,
[activity.key]: activity.reducer,
studio: studioReducer
}
);
/* global Promise */
import InfiniteList from '../../../src/redux/infinite-list';
const module = InfiniteList('test-key');
let initialState;
describe('Infinite List redux module', () => {
beforeEach(() => {
initialState = module.reducer(undefined, {}); // eslint-disable-line no-undefined
});
describe('reducer', () => {
test('module contains a reducer', () => {
expect(typeof module.reducer).toBe('function');
});
test('initial state', () => {
expect(initialState).toMatchObject({
loading: true,
error: null,
items: [],
moreToLoad: false
});
});
describe('LOADING', () => {
let action;
beforeEach(() => {
action = module.actions.loading();
initialState.loading = false;
initialState.items = [1, 2, 3];
initialState.error = new Error();
});
test('sets the loading state', () => {
const newState = module.reducer(initialState, action);
expect(newState.loading).toBe(true);
});
test('maintains any existing data', () => {
const newState = module.reducer(initialState, action);
expect(newState.items).toBe(initialState.items);
});
test('clears any existing error', () => {
const newState = module.reducer(initialState, action);
expect(newState.error).toBe(null);
});
});
describe('APPEND', () => {
let action;
beforeEach(() => {
action = module.actions.append([4, 5, 6], true);
});
test('appends the new items', () => {
initialState.items = [1, 2, 3];
const newState = module.reducer(initialState, action);
expect(newState.items).toEqual([1, 2, 3, 4, 5, 6]);
});
test('sets the moreToLoad state', () => {
initialState.moreToLoad = false;
const newState = module.reducer(initialState, action);
expect(newState.moreToLoad).toEqual(true);
});
test('clears any existing error and loading state', () => {
initialState.error = new Error();
initialState.loading = true;
const newState = module.reducer(initialState, action);
expect(newState.error).toBe(null);
expect(newState.error).toBe(null);
});
});
describe('REPLACE', () => {
let action;
beforeEach(() => {
action = module.actions.replace(2, 55);
});
test('replaces the given index with the new item', () => {
initialState.items = [8, 9, 10, 11];
const newState = module.reducer(initialState, action);
expect(newState.items).toEqual([8, 9, 55, 11]);
});
});
describe('REMOVE', () => {
let action;
beforeEach(() => {
action = module.actions.remove(2);
});
test('removes the given index', () => {
initialState.items = [8, 9, 10, 11];
const newState = module.reducer(initialState, action);
expect(newState.items).toEqual([8, 9, 11]);
});
});
describe('CREATE', () => {
let action;
beforeEach(() => {
action = module.actions.create(7);
});
test('prepends the given item', () => {
initialState.items = [8, 9, 10, 11];
const newState = module.reducer(initialState, action);
expect(newState.items).toEqual([7, 8, 9, 10, 11]);
});
});
describe('ERROR', () => {
let action;
let error = new Error();
beforeEach(() => {
action = module.actions.error(error);
});
test('sets the error state', () => {
const newState = module.reducer(initialState, action);
expect(newState.error).toBe(error);
});
test('resets loading to false', () => {
initialState.loading = true;
const newState = module.reducer(initialState, action);
expect(newState.loading).toBe(false);
});
test('maintains any existing data', () => {
initialState.items = [1, 2, 3];
const newState = module.reducer(initialState, action);
expect(newState.items).toEqual([1, 2, 3]);
});
});
});
describe('action creators', () => {
test('module contains actions creators', () => {
// The actual action creators are tested above in the reducer tests
for (let key in module.actions) {
expect(typeof module.actions[key]).toBe('function');
}
});
describe('loadMore', () => {
test('returns a thunk function, rather than a standard action object', () => {
expect(typeof module.actions.loadMore()).toBe('function');
});
test('calls loading and the fetcher', () => {
let dispatch = jest.fn();
let fetcher = jest.fn(() => new Promise(() => { })); // that never resolves
module.actions.loadMore(fetcher)(dispatch);
expect(dispatch).toHaveBeenCalledWith(module.actions.loading());
expect(fetcher).toHaveBeenCalled();
});
test('calls append with resolved result from fetcher', async () => {
let dispatch = jest.fn();
let fetcher = jest.fn(() => Promise.resolve({items: ['a', 'b'], moreToLoad: false}));
await module.actions.loadMore(fetcher)(dispatch);
expect(dispatch.mock.calls[1][0]) // the second call to dispatch, after LOADING
.toEqual(module.actions.append(['a', 'b'], false));
});
test('calls error with rejecting promise from fetcher', async () => {
let error = new Error();
let dispatch = jest.fn();
let fetcher = jest.fn(() => Promise.reject(error));
await module.actions.loadMore(fetcher)(dispatch);
expect(dispatch.mock.calls[1][0]) // the second call to dispatch, after LOADING
.toEqual(module.actions.error(error));
});
});
});
describe('selector', () => {
test('will return the slice of state defined by the key', () => {
const state = {
[module.key]: module.reducer(undefined, {}) // eslint-disable-line no-undefined
};
expect(module.selector(state)).toBe(initialState);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment