diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 1075d0e8..356b9b36 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -239,6 +239,16 @@ def update_proposal(milestones, proposal_id, **kwargs): return proposal_schema.dump(g.current_proposal), 200 +@blueprint.route("//rfp", methods=["DELETE"]) +@requires_team_member_auth +@endpoint.api() +def unlink_proposal_from_rfp(proposal_id): + g.current_proposal.rfp_id = None + db.session.add(g.current_proposal) + db.session.commit() + return proposal_schema.dump(g.current_proposal), 200 + + @blueprint.route("/", methods=["DELETE"]) @requires_team_member_auth @endpoint.api() diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 45320df1..acfbe1ca 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -229,6 +229,10 @@ export async function putProposalPublish( }); } +export async function deleteProposalRFPLink(proposalId: number): Promise { + return axios.delete(`/api/v1/proposals/${proposalId}/rfp`); +} + export async function requestProposalPayout( proposalId: number, milestoneId: number, diff --git a/frontend/client/components/ContributionModal/PaymentInfo.tsx b/frontend/client/components/ContributionModal/PaymentInfo.tsx index f7731951..8a2acb55 100644 --- a/frontend/client/components/ContributionModal/PaymentInfo.tsx +++ b/frontend/client/components/ContributionModal/PaymentInfo.tsx @@ -47,13 +47,11 @@ export default class PaymentInfo extends React.Component { return (
- {text || ( - <> - Thank you for contributing! Just send using whichever method works best for - you, and we'll let you know when your contribution has been confirmed. - - )} - {/* TODO: Help / FAQ page for sending */} Need help sending? Click here. + {text || + ` + Thank you for contributing! Just send using whichever method works best for + you, and we'll let you know when your contribution has been confirmed. + `}
; + updateForm(form: Partial): void; +} + +interface StateProps { + isUnlinkingProposalRFP: AppState['create']['isUnlinkingProposalRFP']; + unlinkProposalRFPError: AppState['create']['unlinkProposalRFPError']; +} + +interface DispatchProps { + unlinkProposalRFP: typeof unlinkProposalRFP; +} + +type Props = OwnProps & StateProps & DispatchProps; interface State extends Partial { title: string; @@ -15,12 +35,7 @@ interface State extends Partial { rfp?: RFP; } -interface Props { - initialState?: Partial; - updateForm(form: Partial): void; -} - -export default class CreateFlowBasics extends React.Component { +class CreateFlowBasics extends React.Component { constructor(props: Props) { super(props); this.state = { @@ -32,22 +47,22 @@ export default class CreateFlowBasics extends React.Component { }; } - handleInputChange = ( - event: React.ChangeEvent, - ) => { - const { value, name } = event.currentTarget; - this.setState({ [name]: value } as any, () => { - this.props.updateForm(this.state); - }); - }; - - handleCategoryChange = (value: SelectValue) => { - this.setState({ category: value as PROPOSAL_CATEGORY }, () => { - this.props.updateForm(this.state); - }); - }; + componentDidUpdate(prevProps: Props) { + const { unlinkProposalRFPError, isUnlinkingProposalRFP } = this.props; + if ( + unlinkProposalRFPError && + unlinkProposalRFPError !== prevProps.unlinkProposalRFPError + ) { + console.error('Failed to unlink request:', unlinkProposalRFPError); + message.error('Failed to unlink request'); + } else if (!isUnlinkingProposalRFP && prevProps.isUnlinkingProposalRFP) { + this.setState({ rfp: undefined }); + message.success('Unlinked proposal from request'); + } + } render() { + const { isUnlinkingProposalRFP } = this.props; const { title, brief, category, target, rfp } = this.state; const errors = getCreateErrors(this.state, true); @@ -63,8 +78,15 @@ export default class CreateFlowBasics extends React.Component { {rfp.title} - . If you didn’t mean to do this, you can delete this proposal and create a - new one. + . If you didn’t mean to do this, or want to unlink it,{' '} + + click here + {' '} + to do so. } style={{ marginBottom: '2rem' }} @@ -138,4 +160,31 @@ export default class CreateFlowBasics extends React.Component { ); } + + private handleInputChange = ( + event: React.ChangeEvent, + ) => { + const { value, name } = event.currentTarget; + this.setState({ [name]: value } as any, () => { + this.props.updateForm(this.state); + }); + }; + + private handleCategoryChange = (value: SelectValue) => { + this.setState({ category: value as PROPOSAL_CATEGORY }, () => { + this.props.updateForm(this.state); + }); + }; + + private unlinkRfp = () => { + this.props.unlinkProposalRFP(this.props.proposalId); + }; } + +export default connect( + state => ({ + isUnlinkingProposalRFP: state.create.isUnlinkingProposalRFP, + unlinkProposalRFPError: state.create.unlinkProposalRFPError, + }), + { unlinkProposalRFP }, +)(CreateFlowBasics); diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 3219ca41..292aca90 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -49,7 +49,6 @@ interface State { isBodyOverflowing: boolean; isUpdateOpen: boolean; isCancelOpen: boolean; - bodyId: string; } export class ProposalDetail extends React.Component { @@ -58,9 +57,10 @@ export class ProposalDetail extends React.Component { isBodyOverflowing: false, isUpdateOpen: false, isCancelOpen: false, - bodyId: `body-${Math.floor(Math.random() * 1000000)}`, }; + bodyEl: HTMLElement | null = null; + componentDidMount() { // always refresh from server this.props.fetchProposal(this.props.proposalId); @@ -87,13 +87,7 @@ export class ProposalDetail extends React.Component { render() { const { user, detail: proposal, isPreview, detailError } = this.props; - const { - isBodyExpanded, - isBodyOverflowing, - isCancelOpen, - isUpdateOpen, - bodyId, - } = this.state; + const { isBodyExpanded, isBodyOverflowing, isCancelOpen, isUpdateOpen } = this.state; const showExpand = !isBodyExpanded && isBodyOverflowing; const wrongProposal = proposal && proposal.proposalId !== this.props.proposalId; @@ -209,7 +203,7 @@ export class ProposalDetail extends React.Component {
(this.bodyEl = el)} className={classnames({ ['Proposal-top-main-block-bodyText']: true, ['is-expanded']: isBodyExpanded, @@ -291,20 +285,17 @@ export class ProposalDetail extends React.Component { }; private checkBodyOverflow = () => { - const { isBodyExpanded, bodyId, isBodyOverflowing } = this.state; - if (isBodyExpanded) { + const { isBodyExpanded, isBodyOverflowing } = this.state; + if (isBodyExpanded || !this.bodyEl) { return; } - // Use id instead of ref because styled component ref doesn't return html element - const bodyEl = document.getElementById(bodyId); - if (!bodyEl) { - return; - } - - if (isBodyOverflowing && bodyEl.scrollHeight <= bodyEl.clientHeight) { + if (isBodyOverflowing && this.bodyEl.scrollHeight <= this.bodyEl.clientHeight) { this.setState({ isBodyOverflowing: false }); - } else if (!isBodyOverflowing && bodyEl.scrollHeight > bodyEl.clientHeight) { + } else if ( + !isBodyOverflowing && + this.bodyEl.scrollHeight > this.bodyEl.clientHeight + ) { this.setState({ isBodyOverflowing: true }); } }; diff --git a/frontend/client/components/RFP/index.less b/frontend/client/components/RFP/index.less index b9ae68f2..74513ef7 100644 --- a/frontend/client/components/RFP/index.less +++ b/frontend/client/components/RFP/index.less @@ -23,11 +23,22 @@ } } + &-brief { + font-size: 1rem; + margin-bottom: 1.75rem; + text-align: center; + } + + &-tags { + text-align: center; + margin-bottom: 1.75rem; + } + &-title { font-size: 2.4rem; text-align: center; font-weight: bold; - margin-bottom: 1.75rem; + margin-bottom: 1rem; } &-content { diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx index 3600ac3c..44642334 100644 --- a/frontend/client/components/RFP/index.tsx +++ b/frontend/client/components/RFP/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import moment from 'moment'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -import { Icon, Button, Affix } from 'antd'; +import { Icon, Button, Affix, Tag } from 'antd'; import ExceptionPage from 'components/ExceptionPage'; import { fetchRfp } from 'modules/rfps/actions'; import { getRfp } from 'modules/rfps/selectors'; @@ -47,17 +47,40 @@ class RFPDetail extends React.Component { } } + const tags = []; + + if (rfp.matching) { + tags.push( + + x2 matching + , + ); + } + + if (rfp.bounty) { + tags.push( + + bounty + , + ); + } + return (
Back to Requests +
Opened {moment(rfp.dateOpened * 1000).format('LL')}
+

{rfp.title}

+
{tags}
+

{rfp.brief}

+
    diff --git a/frontend/client/modules/create/actions.ts b/frontend/client/modules/create/actions.ts index 94878d6f..a34e4d77 100644 --- a/frontend/client/modules/create/actions.ts +++ b/frontend/client/modules/create/actions.ts @@ -1,7 +1,11 @@ import { Dispatch } from 'redux'; import { ProposalDraft } from 'types'; import types, { CreateDraftOptions } from './types'; -import { putProposal, putProposalSubmitForApproval } from 'api/api'; +import { + putProposal, + putProposalSubmitForApproval, + deleteProposalRFPLink, +} from 'api/api'; export function initializeForm(proposalId: number) { return { @@ -68,3 +72,20 @@ export function submitProposal(form: ProposalDraft) { } }; } + +export function unlinkProposalRFP(proposalId: number) { + return async (dispatch: Dispatch) => { + dispatch({ type: types.UNLINK_PROPOSAL_RFP_PENDING }); + try { + await deleteProposalRFPLink(proposalId); + dispatch({ type: types.UNLINK_PROPOSAL_RFP_FULFILLED }); + dispatch(fetchDrafts()); + } catch (err) { + dispatch({ + type: types.UNLINK_PROPOSAL_RFP_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } + }; +} diff --git a/frontend/client/modules/create/reducers.ts b/frontend/client/modules/create/reducers.ts index 4c1cc1a2..a670c1c2 100644 --- a/frontend/client/modules/create/reducers.ts +++ b/frontend/client/modules/create/reducers.ts @@ -28,6 +28,9 @@ export interface CreateState { publishedProposal: Proposal | null; isPublishing: boolean; publishError: string | null; + + isUnlinkingProposalRFP: boolean; + unlinkProposalRFPError: string | null; } export const INITIAL_STATE: CreateState = { @@ -57,6 +60,9 @@ export const INITIAL_STATE: CreateState = { publishedProposal: null, isPublishing: false, publishError: null, + + isUnlinkingProposalRFP: false, + unlinkProposalRFPError: null, }; export default function createReducer( @@ -190,6 +196,24 @@ export default function createReducer( submitError: action.payload, isSubmitting: false, }; + + case types.UNLINK_PROPOSAL_RFP_PENDING: + return { + ...state, + isUnlinkingProposalRFP: true, + unlinkProposalRFPError: null, + }; + case types.UNLINK_PROPOSAL_RFP_FULFILLED: + return { + ...state, + isUnlinkingProposalRFP: false, + }; + case types.UNLINK_PROPOSAL_RFP_REJECTED: + return { + ...state, + isUnlinkingProposalRFP: false, + unlinkProposalRFPError: action.payload, + }; } return state; } diff --git a/frontend/client/modules/create/types.ts b/frontend/client/modules/create/types.ts index 835e1f23..72b63563 100644 --- a/frontend/client/modules/create/types.ts +++ b/frontend/client/modules/create/types.ts @@ -32,6 +32,11 @@ enum CreateTypes { SUBMIT_PROPOSAL_PENDING = 'SUBMIT_PROPOSAL_PENDING', SUBMIT_PROPOSAL_FULFILLED = 'SUBMIT_PROPOSAL_FULFILLED', SUBMIT_PROPOSAL_REJECTED = 'SUBMIT_PROPOSAL_REJECTED', + + UNLINK_PROPOSAL_RFP = 'UNLINK_PROPOSAL_RFP', + UNLINK_PROPOSAL_RFP_PENDING = 'UNLINK_PROPOSAL_RFP_PENDING', + UNLINK_PROPOSAL_RFP_FULFILLED = 'UNLINK_PROPOSAL_RFP_FULFILLED', + UNLINK_PROPOSAL_RFP_REJECTED = 'UNLINK_PROPOSAL_RFP_REJECTED', } export interface CreateDraftOptions { diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 03d695bc..7caa30fa 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -145,6 +145,12 @@ export function massageSerializedState(state: AppState) { (state.proposal.detail.funded as any) as string, 16, ); + if (state.proposal.detail.rfp && state.proposal.detail.rfp.bounty) { + state.proposal.detail.rfp.bounty = new BN( + (state.proposal.detail.rfp.bounty as any) as string, + 16, + ); + } } // proposals state.proposal.page.items = state.proposal.page.items.map(p => ({