diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index b3761d10..5c33a505 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -27,6 +27,22 @@ proposal_team = db.Table( db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id')) ) +class ProposalUpdate(db.Model): + __tablename__ = "proposal_update" + + id = db.Column(db.Integer(), primary_key=True) + date_created = db.Column(db.DateTime) + + proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) + title = db.Column(db.String(255), nullable=False) + content = db.Column(db.Text, nullable=False) + + def __init__(self, proposal_id: int, title: str, content: str): + self.proposal_id = proposal_id + self.title = title + self.content = content + self.date_created = datetime.datetime.now() + class Proposal(db.Model): __tablename__ = "proposal" @@ -42,6 +58,7 @@ class Proposal(db.Model): team = db.relationship("User", secondary=proposal_team) comments = db.relationship(Comment, backref="proposal", lazy=True) + updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True) milestones = db.relationship("Milestone", backref="proposal", lazy=True) def __init__( @@ -90,6 +107,7 @@ class ProposalSchema(ma.Schema): "proposal_id", "body", "comments", + "updates", "milestones", "category", "team" @@ -100,6 +118,7 @@ class ProposalSchema(ma.Schema): body = ma.Method("get_body") comments = ma.Nested("CommentSchema", many=True) + updates = ma.Nested("ProposalUpdateSchema", many=True) team = ma.Nested("UserSchema", many=True) milestones = ma.Nested("MilestoneSchema", many=True) @@ -115,3 +134,28 @@ class ProposalSchema(ma.Schema): proposal_schema = ProposalSchema() proposals_schema = ProposalSchema(many=True) + + +class ProposalUpdateSchema(ma.Schema): + class Meta: + model = ProposalUpdate + # Fields to expose + fields = ( + "date_created", + "proposal_id", + "title", + "content" + ) + + date_created = ma.Method("get_date_created") + proposal_id = ma.Method("get_proposal_id") + + def get_proposal_id(self, obj): + return obj.proposal_id + + def get_date_created(self, obj): + return dt_to_unix(obj.date_created) + + +proposal_update_schema = ProposalUpdateSchema() +proposals_update_schema = ProposalUpdateSchema(many=True) \ No newline at end of file diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 60b124ac..7001349f 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError from grant.comment.models import Comment, comment_schema from grant.milestone.models import Milestone from grant.user.models import User, SocialMedia, Avatar -from .models import Proposal, proposals_schema, proposal_schema, db +from .models import Proposal, proposals_schema, proposal_schema, ProposalUpdate, proposal_update_schema, db blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals") @@ -159,3 +159,51 @@ def make_proposal(crowd_fund_contract_address, content, title, milestones, categ results = proposal_schema.dump(proposal) return results, 201 + + +@blueprint.route("//updates", methods=["GET"]) +@endpoint.api() +def get_proposal_updates(proposal_id): + proposal = Proposal.query.filter_by(proposal_id=proposal_id).first() + if proposal: + dumped_proposal = proposal_schema.dump(proposal) + return dumped_proposal["updates"] + else: + return {"message": "No proposal matching id"}, 404 + + +@blueprint.route("//updates/", methods=["GET"]) +@endpoint.api() +def get_proposal_update(proposal_id, update_id): + proposal = Proposal.query.filter_by(proposal_id=proposal_id).first() + if proposal: + update = ProposalUpdate.query.filter_by(proposal_id=proposal.id, id=update_id).first() + if update: + return update + else: + return {"message": "No update matching id"} + else: + return {"message": "No proposal matching id"}, 404 + + +# TODO: Add authentication to endpoint +@blueprint.route("//updates", methods=["POST"]) +@endpoint.api( + parameter('title', type=str, required=True), + parameter('content', type=str, required=True) +) +def post_proposal_update(proposal_id, title, content): + proposal = Proposal.query.filter_by(proposal_id=proposal_id).first() + if proposal: + update = ProposalUpdate( + proposal_id=proposal.id, + title=title, + content=content + ) + db.session.add(update) + db.session.commit() + + dumped_update = proposal_update_schema.dump(update) + return dumped_update, 201 + else: + return {"message": "No proposal matching id"}, 404 diff --git a/backend/migrations/versions/d0cf0f715331_.py b/backend/migrations/versions/d0cf0f715331_.py new file mode 100644 index 00000000..2e6a1cec --- /dev/null +++ b/backend/migrations/versions/d0cf0f715331_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: d0cf0f715331 +Revises: 5f38d8603897 +Create Date: 2018-10-31 16:47:19.695642 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd0cf0f715331' +down_revision = '5f38d8603897' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_update', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('proposal_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('proposal_update') + # ### end Alembic commands ### diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index aa542d56..1f154f01 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -1,5 +1,5 @@ import axios from './axios'; -import { Proposal, TeamMember } from 'types'; +import { Proposal, TeamMember, Update } from 'types'; import { formatTeamMemberForPost, formatTeamMemberFromGet } from 'utils/api'; import { PROPOSAL_CATEGORY } from './constants'; @@ -76,4 +76,16 @@ export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> { export function verifyEmail(code: string): Promise { return axios.post(`/api/v1/email/${code}/verify`); +} + +export function postProposalUpdate( + proposalId: string, + title: string, + content: string, +): Promise<{ data: Update }> { + return axios + .post(`/api/v1/proposals/${proposalId}/updates`, { + title, + content, + }); } \ No newline at end of file diff --git a/frontend/client/components/Markdown.tsx b/frontend/client/components/Markdown.tsx index f89f76e9..b01296a7 100644 --- a/frontend/client/components/Markdown.tsx +++ b/frontend/client/components/Markdown.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import classnames from 'classnames'; import { convert, MARKDOWN_TYPE } from 'utils/markdown'; import './Markdown.less'; @@ -15,8 +16,8 @@ export default class Markdown extends React.PureComponent { const divProps = rest as any; return (
); diff --git a/frontend/client/components/Proposal/UpdateModal/index.tsx b/frontend/client/components/Proposal/UpdateModal/index.tsx new file mode 100644 index 00000000..b8c5ce0f --- /dev/null +++ b/frontend/client/components/Proposal/UpdateModal/index.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Modal, Alert, Input, Button } from 'antd'; +import Result from 'ant-design-pro/lib/Result'; +import { fetchProposalUpdates } from 'modules/proposals/actions'; +import { postProposalUpdate } from 'api/api'; +import MarkdownEditor from 'components/MarkdownEditor'; +import './style.less'; + +interface DispatchProps { + fetchProposalUpdates: typeof fetchProposalUpdates +} + +interface OwnProps { + proposalId: string; + isVisible: boolean; + handleClose(): void; +} + +type Props = DispatchProps & OwnProps; + +interface State { + title: string; + content: string; + isSubmitting: boolean; + hasSubmitted: boolean; + error: string | null; +} + +const INITIAL_STATE = { + title: '', + content: '', + isSubmitting: false, + hasSubmitted: false, + error: null, +} + +class UpdateModal extends React.Component { + state: State = { ...INITIAL_STATE }; + + componentDidUpdate(prevProps: Props) { + if (prevProps.isVisible && !this.props.isVisible) { + this.setState({ ...INITIAL_STATE }); + } + } + + render() { + const { isVisible } = this.props; + const { isSubmitting, hasSubmitted, error, title, content } = this.state; + const isMissingFields = !title || !content; + + return ( + +
+ {hasSubmitted ? ( + Close} + /> + ) : ( +
+ + +
+ )} + {error && ( + + )} +
+
+ ); + } + + private handleChangeTitle = (ev: React.ChangeEvent) => { + this.setState({ title: ev.currentTarget.value }); + }; + + private handleChangeContent = (markdown: string) => { + this.setState({ content: markdown }); + }; + + private closeModal = () => { + const { isSubmitting, hasSubmitted, title, content } = this.state; + if (!isSubmitting) { + const empty = !title && !content; + if (empty || hasSubmitted || confirm('Are you sure you want to close? You’ll lose this draft.')) { + this.props.handleClose(); + } + } + }; + + private postUpdate = () => { + const { proposalId } = this.props; + const { title, content, hasSubmitted, isSubmitting } = this.state; + + if (hasSubmitted || isSubmitting) { + return; + } + + this.setState({ isSubmitting: true }); + postProposalUpdate(proposalId, title, content) + .then(() => { + this.setState({ + hasSubmitted: true, + isSubmitting: false, + }); + this.props.fetchProposalUpdates(proposalId); + }) + .catch(err => { + this.setState({ + error: err.message || err.toString(), + isSubmitting: false, + }); + }); + }; +} + +export default connect(undefined, { fetchProposalUpdates })(UpdateModal); diff --git a/frontend/client/components/Proposal/UpdateModal/style.less b/frontend/client/components/Proposal/UpdateModal/style.less new file mode 100644 index 00000000..32af6f5f --- /dev/null +++ b/frontend/client/components/Proposal/UpdateModal/style.less @@ -0,0 +1,13 @@ +@import '~styles/variables.less'; + +.UpdateModal { + &-form { + &-title { + margin-bottom: 1rem; + } + } + + .ant-alert { + margin-top: 1rem; + } +} \ No newline at end of file diff --git a/frontend/client/components/Proposal/Updates/FullUpdate.less b/frontend/client/components/Proposal/Updates/FullUpdate.less new file mode 100644 index 00000000..b2abdbe3 --- /dev/null +++ b/frontend/client/components/Proposal/Updates/FullUpdate.less @@ -0,0 +1,33 @@ +.FullUpdate { + padding-left: 0.8rem; + + &-back { + display: inline-block; + margin-bottom: 1rem; + opacity: 0.8; + + &:hover { + opacity: 1; + } + + .anticon { + font-size: 0.8rem; + } + } + + &-title { + font-size: 2em; + margin-bottom: 0.2rem; + } + + &-date { + font-size: 1rem; + opacity: 0.5; + margin-top: -0.2rem; + margin-bottom: 1rem; + } + + &-body { + font-size: 1.1rem; + } +} \ No newline at end of file diff --git a/frontend/client/components/Proposal/Updates/FullUpdate.tsx b/frontend/client/components/Proposal/Updates/FullUpdate.tsx new file mode 100644 index 00000000..74a506d9 --- /dev/null +++ b/frontend/client/components/Proposal/Updates/FullUpdate.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import moment from 'moment'; +import { Icon } from 'antd'; +import { Update } from 'types'; +import Markdown from 'components/Markdown'; +import './FullUpdate.less'; + +interface Props { + update: Update; + goBack(): void; +} + +export default class FullUpdate extends React.Component { + render() { + const { update, goBack } = this.props; + + return ( +
+ + Back to Updates + +

{update.title}

+
+ {moment(update.dateCreated * 1000).format('MMMM Do, YYYY')} +
+ +
+ ); + } +} diff --git a/frontend/client/components/Proposal/Updates/index.tsx b/frontend/client/components/Proposal/Updates/index.tsx index c775ba74..4c4bc92f 100644 --- a/frontend/client/components/Proposal/Updates/index.tsx +++ b/frontend/client/components/Proposal/Updates/index.tsx @@ -3,8 +3,10 @@ import { connect } from 'react-redux'; import { Spin } from 'antd'; import Markdown from 'components/Markdown'; import moment from 'moment'; +import Placeholder from 'components/Placeholder'; +import FullUpdate from './FullUpdate'; import { AppState } from 'store/reducers'; -import { ProposalWithCrowdFund } from 'types'; +import { ProposalWithCrowdFund, Update } from 'types'; import { fetchProposalUpdates } from 'modules/proposals/actions'; import { getProposalUpdates, @@ -29,7 +31,15 @@ interface DispatchProps { type Props = DispatchProps & OwnProps & StateProps; -class ProposalUpdates extends React.Component { +interface State { + activeUpdate: Update | null; +} + +class ProposalUpdates extends React.Component { + state: State = { + activeUpdate: null, + }; + componentDidMount() { if (this.props.proposalId) { this.props.fetchProposalUpdates(this.props.proposalId); @@ -44,30 +54,45 @@ class ProposalUpdates extends React.Component { render() { const { updates, isFetchingUpdates, updatesError } = this.props; + const { activeUpdate } = this.state; let content = null; if (isFetchingUpdates) { content = ; } else if (updatesError) { content = ( - <> -

Something went wrong

-

{updatesError}

- + ); } else if (updates) { - if (updates.length) { + if (activeUpdate) { + content = ( + this.setActiveUpdate(null)} + /> + ); + } + else if (updates.length) { content = updates.map(update => ( -
+
this.setActiveUpdate(update)} + >

{update.title}

{moment(update.dateCreated * 1000).format('MMMM Do, YYYY')}
- +
- Read more + + Read more + {update.totalComments} comments @@ -76,7 +101,10 @@ class ProposalUpdates extends React.Component { )); } else { content = ( -

No updates have been posted yet

+ ); } } @@ -84,6 +112,10 @@ class ProposalUpdates extends React.Component { return
{content}
; } + private setActiveUpdate = (activeUpdate: Update | null) => { + this.setState({ activeUpdate }); + }; + private truncate(text: string) { if (text.length < 250) { return text; diff --git a/frontend/client/components/Proposal/Updates/style.less b/frontend/client/components/Proposal/Updates/style.less index bc8b8847..5f1b7585 100644 --- a/frontend/client/components/Proposal/Updates/style.less +++ b/frontend/client/components/Proposal/Updates/style.less @@ -1,21 +1,33 @@ .ProposalUpdates { + padding-left: 0.2rem; + max-width: 880px; + &-update { + padding: 1rem; + cursor: pointer; position: relative; - margin-bottom: 3rem; + margin-bottom: 2rem; + border-radius: 8px; + transition: box-shadow 100ms ease; + + &:hover { + box-shadow: 0 0 3px rgba(#000, 0.15); + } &-title { - font-size: 2rem; + font-size: 1.8em; margin-bottom: 0.2rem; } &-date { - font-size: 1.1rem; + font-size: 0.9rem; opacity: 0.5; - margin-bottom: 1.5rem; + margin-top: -0.2rem; + margin-bottom: 1rem; } &-body { - font-size: 1.1rem; + font-size: 1rem; } &-controls { @@ -33,7 +45,7 @@ width: 2px; height: 2px; border-radius: 100%; - background: #888; + background: #AAA; } &:last-child:after { @@ -42,11 +54,4 @@ } } } - - &-noUpdates { - text-align: center; - font-size: 3rem; - line-height: 10rem; - opacity: 0.5; - } } diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 0c60538e..3943a7ba 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -16,6 +16,7 @@ import UpdatesTab from './Updates'; import GovernanceTab from './Governance'; import ContributorsTab from './Contributors'; // import CommunityTab from './Community'; +import UpdateModal from './UpdateModal'; import CancelModal from './CancelModal'; import './style.less'; import classnames from 'classnames'; @@ -46,6 +47,7 @@ type Props = StateProps & DispatchProps & Web3Props & OwnProps; interface State { isBodyExpanded: boolean; isBodyOverflowing: boolean; + isUpdateOpen: boolean; isCancelOpen: boolean; bodyId: string; } @@ -54,6 +56,7 @@ export class ProposalDetail extends React.Component { state: State = { isBodyExpanded: false, isBodyOverflowing: false, + isUpdateOpen: false, isCancelOpen: false, bodyId: `body-${Math.floor(Math.random() * 1000000)}`, }; @@ -79,7 +82,7 @@ export class ProposalDetail extends React.Component { render() { const { proposal, isPreview, account } = this.props; - const { isBodyExpanded, isBodyOverflowing, isCancelOpen, bodyId } = this.state; + const { isBodyExpanded, isBodyOverflowing, isCancelOpen, isUpdateOpen, bodyId } = this.state; const showExpand = !isBodyExpanded && isBodyOverflowing; if (!proposal) { @@ -94,6 +97,9 @@ export class ProposalDetail extends React.Component { const adminMenu = isTrustee && ( + + Post an Update + alert('Sorry, not yet implemented!')} disabled={!isProposalActive} @@ -177,7 +183,6 @@ export class ProposalDetail extends React.Component { -
{isContributor && ( @@ -191,11 +196,18 @@ export class ProposalDetail extends React.Component { )} {isTrustee && ( - + <> + + + )}
); @@ -225,6 +237,9 @@ export class ProposalDetail extends React.Component { } }; + private openUpdateModal = () => this.setState({ isUpdateOpen: true }); + private closeUpdateModal = () => this.setState({ isUpdateOpen: false }); + private openCancelModal = () => this.setState({ isCancelOpen: true }); private closeCancelModal = () => this.setState({ isCancelOpen: false }); } diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 2399ffbf..9f92ed38 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -100,7 +100,10 @@ export function fetchProposalUpdates(proposalId: ProposalWithCrowdFund['proposal return (dispatch: Dispatch) => { dispatch({ type: types.PROPOSAL_UPDATES, - payload: getProposalUpdates(proposalId), + payload: getProposalUpdates(proposalId).then(res => ({ + proposalId, + updates: res.data, + })), }); }; } diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index ea9aa693..09cf014c 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -78,12 +78,12 @@ function addComments(state: ProposalState, payload: { data: ProposalComments }) }; } -function addUpdates(state: ProposalState, payload: { data: ProposalUpdates }) { +function addUpdates(state: ProposalState, payload: ProposalUpdates) { return { ...state, proposalUpdates: { ...state.proposalUpdates, - [payload.data.proposalId]: payload.data, + [payload.proposalId]: payload, }, isFetchingUpdates: false, }; diff --git a/frontend/client/modules/proposals/selectors.tsx b/frontend/client/modules/proposals/selectors.tsx index 3057c58e..f1f7ee2e 100644 --- a/frontend/client/modules/proposals/selectors.tsx +++ b/frontend/client/modules/proposals/selectors.tsx @@ -51,9 +51,9 @@ export function getProposalUpdates( export function getProposalUpdateCount( state: AppState, proposalId: ProposalWithCrowdFund['proposalId'], -): ProposalUpdates['totalUpdates'] | null { +): number | null { const pu = state.proposal.proposalUpdates[proposalId]; - return pu ? pu.totalUpdates : null; + return pu ? pu.updates.length : null; } export function getIsFetchingUpdates(state: AppState) { diff --git a/frontend/client/styles/markdown-styles-mixin.less b/frontend/client/styles/markdown-styles-mixin.less index ee36b4c0..3e7acde7 100644 --- a/frontend/client/styles/markdown-styles-mixin.less +++ b/frontend/client/styles/markdown-styles-mixin.less @@ -134,7 +134,7 @@ blockquote { margin: 0 0 1rem; - padding: 0 0 0 0.5rem; + padding: 0 0 0 1rem; color: #777; border-left: 4px solid rgba(0, 0, 0, 0.08); diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index 3b1bb308..ca5eeaeb 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -55,7 +55,6 @@ export interface ProposalComments { export interface ProposalUpdates { proposalId: ProposalWithCrowdFund['proposalId']; - totalUpdates: number; updates: Update[]; } diff --git a/frontend/types/update.ts b/frontend/types/update.ts index d9d5c0e1..2d286f7a 100644 --- a/frontend/types/update.ts +++ b/frontend/types/update.ts @@ -1,7 +1,7 @@ export interface Update { updateId: number | string; title: string; - body: string; + content: string; dateCreated: number; totalComments: number; }