mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2024-11-23 15:47:53 -05:00
Add title, description and following editors
This commit is contained in:
parent
c789a27018
commit
82da633e61
8 changed files with 394 additions and 15 deletions
174
src/redux/studio-mutations.js
Normal file
174
src/redux/studio-mutations.js
Normal file
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Studio Mutation Reducer - Responsible for client-side modifications
|
||||
* to studio info / roles. Stores in-progress and error states for updates,
|
||||
* and handles the network requests.
|
||||
*
|
||||
* This reducer DOES NOT store the value of the field being mutated,
|
||||
* or deal with loading that value initially from the server. That is
|
||||
* handled by the studio info and roles reducer.
|
||||
*/
|
||||
const keyMirror = require('keymirror');
|
||||
const api = require('../lib/api');
|
||||
const {selectUsername} = require('./session');
|
||||
const {selectStudioId} = require('./studio');
|
||||
|
||||
const Errors = keyMirror({
|
||||
NETWORK: null,
|
||||
SERVER: null,
|
||||
INAPPROPRIATE: null,
|
||||
PERMISSION: null,
|
||||
UNHANDLED: null
|
||||
});
|
||||
|
||||
const getInitialState = () => ({
|
||||
mutationErrors: {}, // { [field]: <error>, ... }
|
||||
isMutating: {} // { [field]: <boolean>, ... }
|
||||
});
|
||||
|
||||
const studioMutationsReducer = (state, action) => {
|
||||
if (typeof state === 'undefined') {
|
||||
state = getInitialState();
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'START_STUDIO_MUTATION':
|
||||
return {
|
||||
...state,
|
||||
isMutating: {
|
||||
...state.isMutating,
|
||||
[action.field]: true
|
||||
},
|
||||
mutationErrors: {
|
||||
...state.mutationErrors,
|
||||
[action.field]: null
|
||||
}
|
||||
};
|
||||
case 'COMPLETE_STUDIO_MUTATION':
|
||||
return {
|
||||
...state,
|
||||
isMutating: {
|
||||
...state.isMutating,
|
||||
[action.field]: false
|
||||
},
|
||||
mutationErrors: {
|
||||
...state.mutationErrors,
|
||||
[action.field]: action.error
|
||||
}
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Action Creators
|
||||
const startMutation = field => ({
|
||||
type: 'START_STUDIO_MUTATION',
|
||||
field
|
||||
});
|
||||
|
||||
const completeMutation = (field, value, error = null) => ({
|
||||
type: 'COMPLETE_STUDIO_MUTATION',
|
||||
field,
|
||||
value, // Value is used by other reducers listening for this action
|
||||
error
|
||||
});
|
||||
|
||||
// Selectors
|
||||
const selectIsMutatingTitle = state => state.studioMutations.isMutating.title;
|
||||
const selectIsMutatingDescription = state => state.studioMutations.isMutating.description;
|
||||
const selectIsMutatingFollowing = state => state.studioMutations.isMutating.following;
|
||||
const selectTitleMutationError = state => state.studioMutations.mutationErrors.title;
|
||||
const selectDescriptionMutationError = state => state.studioMutations.mutationErrors.description;
|
||||
const selectFollowingMutationError = state => state.studioMutations.mutationErrors.following;
|
||||
|
||||
// Thunks
|
||||
/**
|
||||
* Given a response from `api.js`, normalize the possible
|
||||
* error conditions using the `Errors` object.
|
||||
* @param {object} err - error from api.js
|
||||
* @param {object} body - parsed body
|
||||
* @param {object} res - raw response from api.js
|
||||
* @returns {string} one of Errors.<TYPE> or null
|
||||
*/
|
||||
const normalizeError = (err, body, res) => {
|
||||
if (err) return Errors.NETWORK;
|
||||
if (res.statusCode === 401 || res.statusCode === 403) return Errors.PERMISSION;
|
||||
if (res.statusCode !== 200) return Errors.SERVER;
|
||||
try {
|
||||
if (body.errors.length > 0) {
|
||||
if (body.errors[0] === 'inappropriate-generic') return Errors.INAPPROPRIATE;
|
||||
return Errors.UNHANDLED;
|
||||
}
|
||||
} catch (_) { /* No body.errors[], continue */ }
|
||||
return null;
|
||||
};
|
||||
|
||||
const mutateStudioTitle = value => ((dispatch, getState) => {
|
||||
dispatch(startMutation('title'));
|
||||
api({
|
||||
host: '',
|
||||
uri: `/site-api/galleries/all/${selectStudioId(getState())}/`,
|
||||
method: 'PUT',
|
||||
useCsrf: true,
|
||||
json: {
|
||||
title: value
|
||||
}
|
||||
}, (err, body, res) => {
|
||||
const error = normalizeError(err, body, res);
|
||||
dispatch(completeMutation('title', value, error));
|
||||
});
|
||||
});
|
||||
|
||||
const mutateStudioDescription = value => ((dispatch, getState) => {
|
||||
dispatch(startMutation('description'));
|
||||
api({
|
||||
host: '',
|
||||
uri: `/site-api/galleries/all/${selectStudioId(getState())}/`,
|
||||
method: 'PUT',
|
||||
useCsrf: true,
|
||||
json: {
|
||||
description: value
|
||||
}
|
||||
}, (err, body, res) => {
|
||||
const error = normalizeError(err, body, res);
|
||||
dispatch(completeMutation('description', value, error));
|
||||
});
|
||||
});
|
||||
|
||||
const mutateFollowingStudio = shouldFollow => ((dispatch, getState) => {
|
||||
dispatch(startMutation('following'));
|
||||
const state = getState();
|
||||
const studioId = selectStudioId(state);
|
||||
const username = selectUsername(state);
|
||||
let uri = `/site-api/users/bookmarkers/${studioId}/`;
|
||||
uri += shouldFollow ? 'add/' : 'remove/';
|
||||
uri += `?usernames=${username}`;
|
||||
api({
|
||||
host: '',
|
||||
uri: uri,
|
||||
method: 'PUT',
|
||||
useCsrf: true
|
||||
}, (err, body, res) => {
|
||||
const error = normalizeError(err, body, res);
|
||||
dispatch(completeMutation('following', error ? !shouldFollow : shouldFollow, error));
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getInitialState,
|
||||
studioMutationsReducer,
|
||||
Errors,
|
||||
|
||||
// Thunks
|
||||
mutateStudioTitle,
|
||||
mutateStudioDescription,
|
||||
mutateFollowingStudio,
|
||||
|
||||
// Selectors
|
||||
selectIsMutatingTitle,
|
||||
selectIsMutatingDescription,
|
||||
selectIsMutatingFollowing,
|
||||
selectTitleMutationError,
|
||||
selectDescriptionMutationError,
|
||||
selectFollowingMutationError
|
||||
};
|
|
@ -3,7 +3,10 @@ const keyMirror = require('keymirror');
|
|||
const api = require('../lib/api');
|
||||
const log = require('../lib/log');
|
||||
|
||||
const {selectUserId, selectIsAdmin, selectIsSocial, selectUsername, selectToken} = require('./session');
|
||||
const {
|
||||
selectUserId, selectIsAdmin, selectIsSocial, selectUsername, selectToken,
|
||||
selectIsLoggedIn
|
||||
} = require('./session');
|
||||
|
||||
const Status = keyMirror({
|
||||
FETCHED: null,
|
||||
|
@ -25,7 +28,7 @@ const getInitialState = () => ({
|
|||
rolesStatus: Status.NOT_FETCHED,
|
||||
manager: false,
|
||||
curator: false,
|
||||
follower: false,
|
||||
following: false,
|
||||
invited: false
|
||||
});
|
||||
|
||||
|
@ -53,6 +56,12 @@ const studioReducer = (state, action) => {
|
|||
...state,
|
||||
[action.fetchType]: action.fetchStatus
|
||||
};
|
||||
case 'COMPLETE_STUDIO_MUTATION':
|
||||
if (typeof state[action.field] === 'undefined') return state;
|
||||
return {
|
||||
...state,
|
||||
[action.field]: action.value
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@ -102,7 +111,12 @@ const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state);
|
|||
|
||||
// Data selectors
|
||||
const selectStudioId = state => state.studio.id;
|
||||
|
||||
const selectStudioTitle = state => state.studio.title;
|
||||
const selectStudioDescription = state => state.studio.description;
|
||||
const selectIsLoadingInfo = state => state.studio.infoStatus === Status.FETCHING;
|
||||
const selectIsFollowing = state => state.studio.following;
|
||||
const selectCanFollowStudio = state => selectIsLoggedIn(state);
|
||||
const selectIsLoadingRoles = state => state.studio.rolesStatus === Status.FETCHING;
|
||||
|
||||
// Thunks
|
||||
const getInfo = () => ((dispatch, getState) => {
|
||||
|
@ -158,14 +172,21 @@ module.exports = {
|
|||
// Thunks
|
||||
getInfo,
|
||||
getRoles,
|
||||
setInfo,
|
||||
|
||||
// Selectors
|
||||
selectStudioId,
|
||||
selectStudioTitle,
|
||||
selectStudioDescription,
|
||||
selectIsLoadingInfo,
|
||||
selectIsLoadingRoles,
|
||||
selectIsFollowing,
|
||||
selectCanEditInfo,
|
||||
selectCanAddProjects,
|
||||
selectShowCommentComposer,
|
||||
selectCanDeleteComment,
|
||||
selectCanDeleteCommentWithoutConfirm,
|
||||
selectCanReportComment,
|
||||
selectCanRestoreComment
|
||||
selectCanRestoreComment,
|
||||
selectCanFollowStudio
|
||||
};
|
||||
|
|
56
src/views/studio/studio-description.jsx
Normal file
56
src/views/studio/studio-description.jsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
/* eslint-disable react/jsx-no-bind */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {selectStudioDescription, selectIsLoadingInfo, selectCanEditInfo} from '../../redux/studio';
|
||||
import {
|
||||
mutateStudioDescription, selectIsMutatingDescription, selectDescriptionMutationError
|
||||
} from '../../redux/studio-mutations';
|
||||
|
||||
const StudioDescription = ({
|
||||
descriptionError, isLoading, isMutating, description, canEditInfo, handleUpdate
|
||||
}) => (
|
||||
<div>
|
||||
<h3>Description</h3>
|
||||
{isLoading ? (
|
||||
<h4>Loading...</h4>
|
||||
) : (canEditInfo ? (
|
||||
<label>
|
||||
<textarea
|
||||
rows="5"
|
||||
cols="100"
|
||||
disabled={isMutating}
|
||||
defaultValue={description}
|
||||
onBlur={e => e.target.value !== description &&
|
||||
handleUpdate(e.target.value)}
|
||||
/>
|
||||
{descriptionError && <div>Error mutating description: {descriptionError}</div>}
|
||||
</label>
|
||||
) : (
|
||||
<div>{description}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
StudioDescription.propTypes = {
|
||||
descriptionError: PropTypes.string,
|
||||
canEditInfo: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
isMutating: PropTypes.bool,
|
||||
description: PropTypes.string,
|
||||
handleUpdate: PropTypes.func
|
||||
};
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
description: selectStudioDescription(state),
|
||||
canEditInfo: selectCanEditInfo(state),
|
||||
isLoading: selectIsLoadingInfo(state),
|
||||
isMutating: selectIsMutatingDescription(state),
|
||||
descriptionError: selectDescriptionMutationError(state)
|
||||
}),
|
||||
{
|
||||
handleUpdate: mutateStudioDescription
|
||||
}
|
||||
)(StudioDescription);
|
59
src/views/studio/studio-follow.jsx
Normal file
59
src/views/studio/studio-follow.jsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
/* eslint-disable react/jsx-no-bind */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {selectIsFollowing, selectCanFollowStudio, selectIsLoadingRoles} from '../../redux/studio';
|
||||
import {
|
||||
mutateFollowingStudio, selectIsMutatingFollowing, selectFollowingMutationError
|
||||
} from '../../redux/studio-mutations';
|
||||
|
||||
|
||||
const StudioFollow = ({
|
||||
canFollow,
|
||||
isLoading,
|
||||
isFollowing,
|
||||
isMutating,
|
||||
followingError,
|
||||
handleFollow
|
||||
}) => (
|
||||
<div>
|
||||
<h3>Following</h3>
|
||||
<div>
|
||||
<button
|
||||
disabled={isLoading || isMutating || !canFollow}
|
||||
onClick={() => handleFollow(!isFollowing)}
|
||||
>
|
||||
{isLoading ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
isFollowing ? 'Unfollow' : 'Follow'
|
||||
)}
|
||||
</button>
|
||||
{followingError && <div>Error mutating following: {followingError}</div>}
|
||||
{!canFollow && <div>Must be logged in to follow</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
StudioFollow.propTypes = {
|
||||
canFollow: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
isFollowing: PropTypes.bool,
|
||||
isMutating: PropTypes.bool,
|
||||
followingError: PropTypes.string,
|
||||
handleFollow: PropTypes.func
|
||||
};
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
canFollow: selectCanFollowStudio(state),
|
||||
isLoading: selectIsLoadingRoles(state),
|
||||
isMutating: selectIsMutatingFollowing(state),
|
||||
isFollowing: selectIsFollowing(state),
|
||||
followingError: selectFollowingMutationError(state)
|
||||
}),
|
||||
{
|
||||
handleFollow: mutateFollowingStudio
|
||||
}
|
||||
)(StudioFollow);
|
|
@ -2,11 +2,16 @@ import React, {useEffect} from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import Debug from './debug.jsx';
|
||||
import StudioDescription from './studio-description.jsx';
|
||||
import StudioFollow from './studio-follow.jsx';
|
||||
import StudioTitle from './studio-title.jsx';
|
||||
|
||||
import {selectIsLoggedIn} from '../../redux/session';
|
||||
import {getInfo, getRoles, selectCanEditInfo} from '../../redux/studio';
|
||||
import {getInfo, getRoles} from '../../redux/studio';
|
||||
|
||||
const StudioInfo = ({isLoggedIn, studio, canEditInfo, onLoadInfo, onLoadRoles}) => {
|
||||
const StudioInfo = ({
|
||||
isLoggedIn, studio, onLoadInfo, onLoadRoles
|
||||
}) => {
|
||||
useEffect(() => { // Load studio info after first render
|
||||
onLoadInfo();
|
||||
}, []);
|
||||
|
@ -18,23 +23,22 @@ const StudioInfo = ({isLoggedIn, studio, canEditInfo, onLoadInfo, onLoadRoles})
|
|||
return (
|
||||
<div>
|
||||
<h2>Studio Info</h2>
|
||||
<StudioTitle />
|
||||
<StudioDescription />
|
||||
<StudioFollow />
|
||||
<Debug
|
||||
label="Studio Info"
|
||||
data={studio}
|
||||
/>
|
||||
<Debug
|
||||
label="Studio Info Permissions"
|
||||
data={{canEditInfo}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
StudioInfo.propTypes = {
|
||||
canEditInfo: PropTypes.bool,
|
||||
isLoggedIn: PropTypes.bool,
|
||||
studio: PropTypes.shape({
|
||||
// Fill this in as the data is used, just for demo now
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.description
|
||||
}),
|
||||
onLoadInfo: PropTypes.func,
|
||||
onLoadRoles: PropTypes.func
|
||||
|
@ -43,8 +47,7 @@ StudioInfo.propTypes = {
|
|||
export default connect(
|
||||
state => ({
|
||||
studio: state.studio,
|
||||
isLoggedIn: selectIsLoggedIn(state),
|
||||
canEditInfo: selectCanEditInfo(state)
|
||||
isLoggedIn: selectIsLoggedIn(state)
|
||||
}),
|
||||
{
|
||||
onLoadInfo: getInfo,
|
||||
|
|
52
src/views/studio/studio-title.jsx
Normal file
52
src/views/studio/studio-title.jsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
/* eslint-disable react/jsx-no-bind */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {selectStudioTitle, selectIsLoadingInfo, selectCanEditInfo} from '../../redux/studio';
|
||||
import {mutateStudioTitle, selectIsMutatingTitle, selectTitleMutationError} from '../../redux/studio-mutations';
|
||||
|
||||
const StudioTitle = ({
|
||||
titleError, isLoading, isMutating, title, canEditInfo, handleUpdate
|
||||
}) => (
|
||||
<div>
|
||||
<h3>Title</h3>
|
||||
{isLoading ? (
|
||||
<h4>Loading...</h4>
|
||||
) : (canEditInfo ? (
|
||||
<label>
|
||||
<input
|
||||
disabled={isMutating}
|
||||
defaultValue={title}
|
||||
onBlur={e => e.target.value !== title &&
|
||||
handleUpdate(e.target.value)}
|
||||
/>
|
||||
{titleError && <div>Error mutating title: {titleError}</div>}
|
||||
</label>
|
||||
) : (
|
||||
<div>{title}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
StudioTitle.propTypes = {
|
||||
titleError: PropTypes.string,
|
||||
canEditInfo: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
isMutating: PropTypes.bool,
|
||||
title: PropTypes.string,
|
||||
handleUpdate: PropTypes.func
|
||||
};
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
title: selectStudioTitle(state),
|
||||
canEditInfo: selectCanEditInfo(state),
|
||||
isLoading: selectIsLoadingInfo(state),
|
||||
isMutating: selectIsMutatingTitle(state),
|
||||
titleError: selectTitleMutationError(state)
|
||||
}),
|
||||
{
|
||||
handleUpdate: mutateStudioTitle
|
||||
}
|
||||
)(StudioTitle);
|
|
@ -24,8 +24,9 @@ import {
|
|||
activity
|
||||
} from './lib/redux-modules';
|
||||
|
||||
const {studioReducer} = require('../../redux/studio');
|
||||
const {getInitialState, studioReducer} = require('../../redux/studio');
|
||||
const {commentsReducer} = require('../../redux/comments');
|
||||
const {studioMutationsReducer} = require('../../redux/studio-mutations');
|
||||
|
||||
const StudioShell = () => {
|
||||
const match = useRouteMatch();
|
||||
|
@ -77,10 +78,12 @@ render(
|
|||
[managers.key]: managers.reducer,
|
||||
[activity.key]: activity.reducer,
|
||||
studio: studioReducer,
|
||||
studioMutations: studioMutationsReducer,
|
||||
comments: commentsReducer
|
||||
},
|
||||
{
|
||||
studio: {
|
||||
...getInitialState(),
|
||||
// Include the studio id in the initial state to allow us
|
||||
// to stop passing around the studio id in components
|
||||
// when it is only needed for data fetching, not for rendering.
|
||||
|
|
|
@ -167,4 +167,15 @@ describe('studio comments', () => {
|
|||
expect(selectCanRestoreComment(state)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('can follow a studio', () => {
|
||||
test.each([
|
||||
['logged in', true],
|
||||
['unconfirmed', true],
|
||||
['logged out', false]
|
||||
])('%s: %s', (role, expected) => {
|
||||
setStateByRole(role);
|
||||
expect(selectCanFollowStudio(state)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue