From 82da633e61e398623bdd14c309ef2f05928fbce5 Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Wed, 14 Apr 2021 12:21:22 -0400 Subject: [PATCH] Add title, description and following editors --- src/redux/studio-mutations.js | 174 ++++++++++++++++++++++++ src/redux/studio.js | 29 +++- src/views/studio/studio-description.jsx | 56 ++++++++ src/views/studio/studio-follow.jsx | 59 ++++++++ src/views/studio/studio-info.jsx | 23 ++-- src/views/studio/studio-title.jsx | 52 +++++++ src/views/studio/studio.jsx | 5 +- test/unit/redux/studio.test.js | 11 ++ 8 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 src/redux/studio-mutations.js create mode 100644 src/views/studio/studio-description.jsx create mode 100644 src/views/studio/studio-follow.jsx create mode 100644 src/views/studio/studio-title.jsx diff --git a/src/redux/studio-mutations.js b/src/redux/studio-mutations.js new file mode 100644 index 000000000..0c4c4ffe6 --- /dev/null +++ b/src/redux/studio-mutations.js @@ -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]: , ... } + isMutating: {} // { [field]: , ... } +}); + +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. 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 +}; diff --git a/src/redux/studio.js b/src/redux/studio.js index 2a9e1f51b..9b2e95993 100644 --- a/src/redux/studio.js +++ b/src/redux/studio.js @@ -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 }; diff --git a/src/views/studio/studio-description.jsx b/src/views/studio/studio-description.jsx new file mode 100644 index 000000000..bee969bce --- /dev/null +++ b/src/views/studio/studio-description.jsx @@ -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 +}) => ( +
+

Description

+ {isLoading ? ( +

Loading...

+ ) : (canEditInfo ? ( +