diff --git a/package-lock.json b/package-lock.json index 5c6ddc0f..6034dbbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14822,6 +14822,11 @@ "deep-diff": "^0.3.5" } }, + "redux-promise-middleware": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux-promise-middleware/-/redux-promise-middleware-4.2.1.tgz", + "integrity": "sha1-UMSW+bVGpotR+oUJvi4K1pvnYkY=" + }, "redux-thunk": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", diff --git a/package.json b/package.json index 787e59a9..3635e879 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "react-tabs": "^3.0.0", "redux": "^4.0.1", "redux-logger": "^3.0.6", + "redux-promise-middleware": "4.2.1", "redux-thunk": "^2.3.0", "resolve": "1.8.1", "sass-loader": "7.1.0", diff --git a/src/actions/challenges.js b/src/actions/challenges.js index a18bfd91..6661b4c5 100644 --- a/src/actions/challenges.js +++ b/src/actions/challenges.js @@ -15,7 +15,9 @@ import { fetchChallengeTracks, updateChallenge, patchChallenge, - createChallenge as createChallengeAPI + createChallenge as createChallengeAPI, + createResource as createResourceAPI, + deleteResource as deleteResourceAPI } from '../services/challenges' import { LOAD_CHALLENGE_DETAILS_PENDING, @@ -32,6 +34,8 @@ import { LOAD_CHALLENGE_RESOURCES_PENDING, LOAD_CHALLENGE_RESOURCES_SUCCESS, LOAD_CHALLENGE_RESOURCES_FAILURE, + CREATE_CHALLENGE_RESOURCE, + DELETE_CHALLENGE_RESOURCE, REMOVE_ATTACHMENT, PAGE_SIZE, UPDATE_CHALLENGE_DETAILS_PENDING, @@ -452,3 +456,51 @@ export function loadResourceRoles () { }) } } + +export function deleteResource (challengeId, roleId, memberHandle) { + const resource = { + challengeId, + roleId, + memberHandle + } + return (dispatch, getState) => { + return dispatch({ + type: DELETE_CHALLENGE_RESOURCE, + payload: deleteResourceAPI(resource) + }) + } +} + +export function createResource (challengeId, roleId, memberHandle) { + const resource = { + challengeId, + roleId, + memberHandle + } + return (dispatch, getState) => { + return dispatch({ + type: CREATE_CHALLENGE_RESOURCE, + payload: createResourceAPI(resource) + }) + } +} + +export function replaceResourceInRole (challengeId, roleId, newMember, oldMember) { + return async (dispatch) => { + if (newMember === oldMember) { + return + } + if (oldMember) { + try { + await dispatch(deleteResource(challengeId, roleId, oldMember)) + } catch (error) { + const errorMessage = _.get(error, 'response.data.message') + // ignore error where the resource does not exist already + if (errorMessage.indexOf('doesn\'t have resource with roleId') === -1) { + return Promise.reject(new Error('Unable to delete resource')) + } + } + } + await dispatch(createResource(challengeId, roleId, newMember)) + } +} diff --git a/src/components/Buttons/PrimaryButton/PrimaryButton.module.scss b/src/components/Buttons/PrimaryButton/PrimaryButton.module.scss index f2a856c6..d14556cd 100644 --- a/src/components/Buttons/PrimaryButton/PrimaryButton.module.scss +++ b/src/components/Buttons/PrimaryButton/PrimaryButton.module.scss @@ -20,13 +20,28 @@ &.danger { background-color: $tc-red; + + &:disabled { + cursor: default; + background-color: $inactive; + } } &.info { background-color: $tc-blue-20; + + &:disabled { + cursor: default; + background-color: $inactive; + } } &.success { background-color: $tc-green-40; + + &:disabled { + cursor: default; + background-color: $inactive; + } } } diff --git a/src/components/Buttons/PrimaryButton/index.js b/src/components/Buttons/PrimaryButton/index.js index 48be4d1d..89c9a552 100644 --- a/src/components/Buttons/PrimaryButton/index.js +++ b/src/components/Buttons/PrimaryButton/index.js @@ -6,10 +6,10 @@ import cn from 'classnames' import styles from './PrimaryButton.module.scss' -const PrimaryButton = ({ type, text, link, onClick, submit }) => { +const PrimaryButton = ({ type, text, link, onClick, submit, disabled }) => { if (_.isEmpty(link)) { return ( - ) @@ -26,7 +26,8 @@ PrimaryButton.propTypes = { text: PropTypes.string.isRequired, link: PropTypes.string, onClick: PropTypes.func, - submit: PropTypes.bool + submit: PropTypes.bool, + disabled: PropTypes.bool } export default PrimaryButton diff --git a/src/components/ChallengeEditor/AssignedMember-Field/index.js b/src/components/ChallengeEditor/AssignedMember-Field/index.js index b6ea7b90..4731518d 100644 --- a/src/components/ChallengeEditor/AssignedMember-Field/index.js +++ b/src/components/ChallengeEditor/AssignedMember-Field/index.js @@ -8,12 +8,11 @@ import styles from './AssignedMember-Field.module.scss' import SelectUserAutocomplete from '../../SelectUserAutocomplete' const AssignedMemberField = ({ challenge, onChange, assignedMemberDetails, readOnly }) => { - const value = challenge.task.memberId ? { + const value = assignedMemberDetails ? { // if we know assigned member details, then show user `handle`, otherwise fallback to `userId` - label: assignedMemberDetails ? assignedMemberDetails.handle : `User id: ${challenge.task.memberId}`, - value: challenge.task.memberId + label: assignedMemberDetails.handle, + value: assignedMemberDetails.userId + '' } : null - return (
diff --git a/src/components/ChallengeEditor/FinalDeliverables-Field/FinalDeliverables-Field.module.scss b/src/components/ChallengeEditor/FinalDeliverables-Field/FinalDeliverables-Field.module.scss index aa2f9e96..4dd15523 100644 --- a/src/components/ChallengeEditor/FinalDeliverables-Field/FinalDeliverables-Field.module.scss +++ b/src/components/ChallengeEditor/FinalDeliverables-Field/FinalDeliverables-Field.module.scss @@ -47,104 +47,55 @@ } } - .tcCheckbox { - @include tc-checkbox; - - .tc-checkbox-label { - @include roboto-light(); - - line-height: 17px; - font-weight: 300; - margin-left: 21px; - user-select: none; - cursor: pointer; - width: 195px; - font-size: 14px; - color: #3d3d3d; - } - - height: 18px; - width: 210px; + .fileTypeList { margin: 0; padding: 0; - vertical-align: bottom; - position: relative; - display: inline-block; - margin-bottom: 18px; - - input[type='checkbox'] { - display: none; - - &:checked ~ label { - background: $tc-blue-20; - } - - &:checked + label::after { - border-color: $white; - } - } - - label { - @include roboto-light(); - - line-height: 17px; - font-weight: 300; - cursor: pointer; - position: absolute; - display: inline-block; - width: 14px; - height: 14px; - top: 0; - left: 0; - border: none; - box-shadow: none; - background: $tc-gray-30; - transition: all 0.15s ease-in-out; - - &.readOnly { - cursor: auto; - } + } - &::after { - opacity: 0; - content: ''; - position: absolute; - width: 9px; - height: 5px; - background: transparent; - top: 2px; - left: 2px; - border-top: none; - border-right: none; - transform: rotate(-45deg); - transition: all 0.15s ease-in-out; - } + .fileTypeListEditable { - &:hover::after { - opacity: 0.3; - } - &:hover:read-only::after { - opacity: 0; - } + } - div { - margin-left: 24px; - width: 500px; - } - } + .fileTypeItem { + background-color: #2C95D7; + border-radius: 2px; + border: 1px solid #c9e6f2; + color: #ffffff; + display: inline-block; + font-size: 14px; + margin-right: 5px; + padding: 2px 30px 2px 5px; + position: relative; + vertical-align: top; } - .checkList { + .fileTypeDelete { + align-items: center; + background-color: #c6def1; + border: 0; + color: #2C95D7; + cursor: pointer; display: flex; - flex-direction: column; - margin-left: 30px; + border-radius: 50%; + font-size: 14px; + height: 16px; + justify-content: center; + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + width: 16px; + + &:hover { + background-color: #fff; + } } form { display: flex; flex-direction: row; } - + .button { width: 137px; height: 40px; diff --git a/src/components/ChallengeEditor/FinalDeliverables-Field/index.js b/src/components/ChallengeEditor/FinalDeliverables-Field/index.js index df76eeb7..57ead390 100644 --- a/src/components/ChallengeEditor/FinalDeliverables-Field/index.js +++ b/src/components/ChallengeEditor/FinalDeliverables-Field/index.js @@ -17,12 +17,12 @@ class FinalDeliverablesField extends Component { } onChangeInput (value) { - this.setState({ newFileType: _.trim(value) }) + this.setState({ newFileType: value }) } onAddFileType (event) { if (!_.isEmpty(this.state.newFileType)) { - this.props.addFileType(this.state.newFileType) + this.props.addFileType(this.state.newFileType.trim()) this.setState({ newFileType: '' }) } @@ -31,7 +31,11 @@ class FinalDeliverablesField extends Component { } render () { - const { challenge, onUpdateCheckbox, readOnly } = this.props + const { challenge, readOnly, removeFileType } = this.props + const fileTypesMetadata = _.find(challenge.metadata, { name: 'fileTypes' }) + const fileTypes = (fileTypesMetadata && JSON.parse(fileTypesMetadata.value)) || [] + const isDuplicateValue = _.includes(fileTypes, this.state.newFileType.trim()) + return (
@@ -40,27 +44,18 @@ class FinalDeliverablesField extends Component {
-
- { - _.map(challenge.fileTypes, (type, index) => ( -
- onUpdateCheckbox(type.name, e.target.checked, 'fileTypes', index)} - readOnly={readOnly} - /> - -
- )) - } -
+ {!readOnly ? ( + + ) : ( + fileTypes.join(', ') + )}
{!readOnly && (
@@ -76,6 +71,7 @@ class FinalDeliverablesField extends Component {
@@ -93,8 +89,8 @@ FinalDeliverablesField.defaultProps = { FinalDeliverablesField.propTypes = { challenge: PropTypes.shape().isRequired, - onUpdateCheckbox: PropTypes.func.isRequired, addFileType: PropTypes.func.isRequired, + removeFileType: PropTypes.func.isRequired, readOnly: PropTypes.bool } diff --git a/src/components/ChallengeEditor/TextEditor-Field/index.js b/src/components/ChallengeEditor/TextEditor-Field/index.js index 74d2b57f..303b8968 100644 --- a/src/components/ChallengeEditor/TextEditor-Field/index.js +++ b/src/components/ChallengeEditor/TextEditor-Field/index.js @@ -29,6 +29,7 @@ class TextEditorField extends Component { challenge, onUpdateCheckbox, addFileType, + removeFileType, onUpdateDescription, onUpdateMultiSelect, shouldShowPrivateDescription, @@ -87,6 +88,7 @@ class TextEditorField extends Component { challenge={challenge} onUpdateCheckbox={onUpdateCheckbox} addFileType={addFileType} + removeFileType={removeFileType} readOnly={readOnly} /> -1) { + fileTypesMetadata = { ...newChallenge.metadata[fileTypesMetadataIndex] } + newChallenge.metadata[fileTypesMetadataIndex] = fileTypesMetadata + // if not yet, create an empty record in metadata + } else { + fileTypesMetadata = { name: 'fileTypes', value: '[]' } + newChallenge.metadata.push(fileTypesMetadata) + } + + // as values in metadata are always stored as string, we have to parse it, update and stringify again + const oldFileTypes = JSON.parse(fileTypesMetadata.value) + const newFileTypes = processValue(oldFileTypes) + fileTypesMetadata.value = JSON.stringify(newFileTypes) + this.setState({ challenge: newChallenge }) } + /** + * Add new file type + * @param {String} newFileType The new file type + */ + addFileType (newFileType) { + this.updateFileTypesMetadata((oldFileTypes) => { + const newFileTypes = [...oldFileTypes, newFileType] + + return newFileTypes + }) + } + + /** + * Remove file type + * @param {String} fileType file type + */ + removeFileType (fileType) { + this.updateFileTypesMetadata((oldFileTypes) => { + const newFileTypes = _.reject(oldFileTypes, (type) => type === fileType) + + return newFileTypes + }) + } + /** * Update Metadata * @param name Name of data @@ -851,7 +889,7 @@ class ChallengeEditor extends Component { } async updateAllChallengeInfo (status, cb = () => {}) { - const { updateChallengeDetails } = this.props + const { updateChallengeDetails, assignedMemberDetails: oldAssignedMember } = this.props if (this.state.isSaving) return this.setState({ isSaving: true }) const challenge = this.collectChallengeData(status) @@ -861,9 +899,14 @@ class ChallengeEditor extends Component { const challengeId = this.getCurrentChallengeId() const action = await updateChallengeDetails(challengeId, challenge) const { copilot: previousCopilot, reviewer: previousReviewer } = this.state.draftChallenge.data - const { copilot, reviewer } = this.state.challenge + const { challenge: { copilot, reviewer }, assignedMemberDetails: assignedMember } = this.state if (copilot) await this.updateResource(challengeId, 'Copilot', copilot, previousCopilot) if (reviewer) await this.updateResource(challengeId, 'Reviewer', reviewer, previousReviewer) + const oldMemberHandle = _.get(oldAssignedMember, 'handle') + // assigned member has been updated + if (assignedMember && assignedMember.handle !== oldMemberHandle) { + await this.updateResource(challengeId, 'Submitter', assignedMember.handle, oldMemberHandle) + } const draftChallenge = { data: action.challengeDetails } draftChallenge.data.copilot = copilot @@ -902,21 +945,8 @@ class ChallengeEditor extends Component { async updateResource (challengeId, name, value, prevValue) { const resourceRole = this.getResourceRoleByName(name) - if (value === prevValue) { - return - } - const newResource = { - challengeId, - memberHandle: value, - roleId: resourceRole ? resourceRole.id : null - } - if (prevValue) { - const oldResource = _.pick(newResource, ['challengeId', 'roleId']) - oldResource.memberHandle = prevValue - await deleteResource(oldResource) - } - - await createResource(newResource) + const roleId = resourceRole.id + await this.props.replaceResourceInRole(challengeId, roleId, value, prevValue) } updateAttachmentlist (challenge, attachments) { @@ -1125,7 +1155,6 @@ class ChallengeEditor extends Component { ) } } - if (!isNew && challenge.status !== 'New' && isLaunch && isConfirm) { draftModal = ( + +
+ Give Application Feedback +
+ +

+ Have an urgent issue?
+ E: support@topcoder.com +

) } diff --git a/src/config/constants.js b/src/config/constants.js index 085c81a7..5f4ad9a0 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -67,6 +67,16 @@ export const LOAD_CHALLENGE_RESOURCES_SUCCESS = 'LOAD_CHALLENGE_RESOURCES_SUCCES export const LOAD_CHALLENGE_RESOURCES_PENDING = 'LOAD_CHALLENGE_RESOURCES_PENDING' export const LOAD_CHALLENGE_RESOURCES_FAILURE = 'LOAD_CHALLENGE_RESOURCES_FAILURE' +export const CREATE_CHALLENGE_RESOURCE = 'CREATE_CHALLENGE_RESOURCE' +export const CREATE_CHALLENGE_RESOURCE_SUCCESS = 'CREATE_CHALLENGE_RESOURCE_SUCCESS' +export const CREATE_CHALLENGE_RESOURCE_PENDING = 'CREATE_CHALLENGE_RESOURCE_PENDING' +export const CREATE_CHALLENGE_RESOURCE_FAILURE = 'CREATE_CHALLENGE_RESOURCE_FAILURE' + +export const DELETE_CHALLENGE_RESOURCE = 'DELETE_CHALLENGE_RESOURCE' +export const DELETE_CHALLENGE_RESOURCE_SUCCESS = 'DELETE_CHALLENGE_RESOURCE_SUCCESS' +export const DELETE_CHALLENGE_RESOURCE_PENDING = 'DELETE_CHALLENGE_RESOURCE_PENDING' +export const DELETE_CHALLENGE_RESOURCE_FAILURE = 'DELETE_CHALLENGE_RESOURCE_FAILURE' + export const REMOVE_ATTACHMENT = 'REMOVE_ATTACHMENT' export const SET_FILTER_CHALLENGE_VALUE = 'SET_FILTER_CHALLENGE_VALUE' diff --git a/src/config/store.js b/src/config/store.js index 4c3ed7cb..f37aa811 100644 --- a/src/config/store.js +++ b/src/config/store.js @@ -4,9 +4,15 @@ import { createStore, applyMiddleware } from 'redux' import thunkMiddleware from 'redux-thunk' import { createLogger } from 'redux-logger' +import promiseMiddleware from 'redux-promise-middleware' import reducer from '../reducers' -const middlewares = [thunkMiddleware] +const middlewares = [ + promiseMiddleware({ + promiseTypeSuffixes: ['PENDING', 'SUCCESS', 'FAILURE'] + }), + thunkMiddleware +] if (process.env.NODE_ENV === 'development') { middlewares.push(createLogger()) diff --git a/src/containers/ChallengeEditor/index.js b/src/containers/ChallengeEditor/index.js index fc7257ee..ef853d94 100644 --- a/src/containers/ChallengeEditor/index.js +++ b/src/containers/ChallengeEditor/index.js @@ -22,13 +22,15 @@ import { loadResourceRoles, updateChallengeDetails, partiallyUpdateChallengeDetails, - createChallenge + createChallenge, + replaceResourceInRole } from '../../actions/challenges' import { loadMemberDetails } from '../../actions/members' import { connect } from 'react-redux' +import { SUBMITTER_ROLE_UUID } from '../../config/constants' class ChallengeEditor extends Component { componentDidMount () { @@ -126,14 +128,21 @@ class ChallengeEditor extends Component { updateChallengeDetails, partiallyUpdateChallengeDetails, createChallenge, - members + replaceResourceInRole + // members } = this.props const challengeId = _.get(match.params, 'challengeId', null) if (challengeId && (!challengeDetails || !challengeDetails.id)) { return () } - const assignedMemberId = _.get(challengeDetails, 'task.memberId') - const assignedMemberDetails = _.find(members, (member) => member.userId.toString() === assignedMemberId) + const submitters = challengeResources && challengeResources.filter(cr => cr.roleId === SUBMITTER_ROLE_UUID) + var assignedMemberDetails = null + if (submitters && submitters.length === 1) { + assignedMemberDetails = { + userId: submitters[0].memberId, + handle: submitters[0].memberHandle + } + } return
)) @@ -180,6 +190,7 @@ class ChallengeEditor extends Component { projectDetail={projectDetail} assignedMemberDetails={assignedMemberDetails} updateChallengeDetails={updateChallengeDetails} + replaceResourceInRole={replaceResourceInRole} partiallyUpdateChallengeDetails={partiallyUpdateChallengeDetails} /> )) @@ -240,7 +251,8 @@ ChallengeEditor.propTypes = { updateChallengeDetails: PropTypes.func.isRequired, partiallyUpdateChallengeDetails: PropTypes.func.isRequired, createChallenge: PropTypes.func.isRequired, - members: PropTypes.arrayOf(PropTypes.shape()) + replaceResourceInRole: PropTypes.func + // members: PropTypes.arrayOf(PropTypes.shape()) } const mapStateToProps = ({ projects: { projectDetail }, challenges: { challengeDetails, challengeResources, metadata, isLoading, attachments, failedToLoad }, auth: { token }, members: { members } }) => ({ @@ -251,8 +263,8 @@ const mapStateToProps = ({ projects: { projectDetail }, challenges: { challengeD isLoading, attachments, token, - failedToLoad, - members + failedToLoad + // members }) const mapDispatchToProps = { @@ -272,7 +284,8 @@ const mapDispatchToProps = { loadMemberDetails, updateChallengeDetails, partiallyUpdateChallengeDetails, - createChallenge + createChallenge, + replaceResourceInRole } export default withRouter(connect(mapStateToProps, mapDispatchToProps)(ChallengeEditor)) diff --git a/src/reducers/challenges.js b/src/reducers/challenges.js index d3e73a7d..f9237977 100644 --- a/src/reducers/challenges.js +++ b/src/reducers/challenges.js @@ -23,7 +23,11 @@ import { UPDATE_CHALLENGE_DETAILS_FAILURE, UPDATE_CHALLENGE_DETAILS_SUCCESS, CREATE_CHALLENGE_SUCCESS, - CREATE_CHALLENGE_FAILURE + CREATE_CHALLENGE_FAILURE, + CREATE_CHALLENGE_RESOURCE_SUCCESS, + DELETE_CHALLENGE_RESOURCE_SUCCESS, + DELETE_CHALLENGE_RESOURCE_FAILURE, + CREATE_CHALLENGE_RESOURCE_FAILURE } from '../config/constants' const initialState = { @@ -164,6 +168,43 @@ export default function (state = initialState, action) { isLoading: false, failedToLoad: false } + case CREATE_CHALLENGE_RESOURCE_SUCCESS: { + const resource = action.payload + const challengeResources = _.clone(state.challengeResources) + challengeResources.push(resource) + return { + ...state, + challengeResources, + isLoading: false, + failedToLoad: false + } + } + case CREATE_CHALLENGE_RESOURCE_FAILURE: { + const resource = action.payload + console.log(resource) + return { ...state, isLoading: false, failedToCreate: true } + } + case DELETE_CHALLENGE_RESOURCE_SUCCESS: { + const resource = action.payload + const challengeResources = _.clone(state.challengeResources) + _.remove(challengeResources, + r => r.challengeId === resource.challengeId && r.roleId === resource.roleId && r.memberHandle === resource.memberHandle) + return { + ...state, + challengeResources, + isLoading: false, + failedToLoad: false + } + } + case DELETE_CHALLENGE_RESOURCE_FAILURE: { + const err = action.payload + const errorMessage = _.get(err, 'response.data.message') + // ignore error where the resource does not exist already + if (errorMessage.indexOf('doesn\'t have resource with roleId') === -1) { + return { ...state, isLoading: false, failedToDelete: true } + } + return { ...state, isLoading: false, failedToDelete: false } + } case LOAD_CHALLENGE_METADATA_SUCCESS: return { ...state, diff --git a/src/services/challenges.js b/src/services/challenges.js index 4a96c250..333920b8 100644 --- a/src/services/challenges.js +++ b/src/services/challenges.js @@ -172,8 +172,9 @@ export async function fetchChallengeTerms () { * @param challenge challenge data * @returns {Promise<*>} */ -export function createResource (resource) { - return axiosInstance.post(RESOURCES_API_URL, resource) +export async function createResource (resource) { + const resp = await axiosInstance.post(RESOURCES_API_URL, resource) + return _.get(resp, 'data', {}) } /** @@ -200,6 +201,7 @@ export async function fetchResourceRoles () { * @param {object} resource to delete * @returns {Promise<*>} */ -export function deleteResource (resource) { - return axiosInstance.delete(RESOURCES_API_URL, { data: resource }) +export async function deleteResource (resource) { + const resp = await axiosInstance.delete(RESOURCES_API_URL, { data: resource }) + return _.get(resp, 'data', {}) }