Add title, description and following editors

This commit is contained in:
Paul Kaplan 2021-04-14 12:21:22 -04:00
parent c789a27018
commit 82da633e61
8 changed files with 394 additions and 15 deletions

View 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
};

View file

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

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

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

View file

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

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

View file

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

View file

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