Proposal updates (#171)

* Add models & endpoints

* Implement proposal updates.

* Reset state on close. Add missing key. Prompt on close if they will lose stuff.

* Dont warn if they submitted.
This commit is contained in:
William O'Beirne 2018-11-02 12:24:28 -04:00 committed by GitHub
parent cdc3ea0107
commit 118d7b645e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 459 additions and 42 deletions

View File

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

View File

@ -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("/<proposal_id>/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("/<proposal_id>/updates/<update_id>", 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("/<proposal_id>/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

View File

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

View File

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

View File

@ -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<Props> {
const divProps = rest as any;
return (
<div
className="Markdown"
{...divProps}
className={classnames('Markdown', divProps.className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);

View File

@ -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<Props, State> {
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 (
<Modal
title="Post an Update"
visible={isVisible}
okText="Submit"
cancelText="Cancel"
onOk={this.postUpdate}
onCancel={this.closeModal}
okButtonProps={{
loading: isSubmitting,
disabled: isMissingFields,
}}
cancelButtonProps={{ disabled: isSubmitting }}
footer={hasSubmitted ? '' : undefined}
width={800}
centered
>
<div className="UpdateModal">
{hasSubmitted ? (
<Result
type="success"
title="Update has been posted"
description="Your funders have been notified, thanks for updating them"
actions={<Button onClick={this.closeModal}>Close</Button>}
/>
) : (
<div className="UpdateModal-form">
<Input
className="UpdateModal-form-title"
size="large"
value={title}
placeholder="Title (60 char max)"
onChange={this.handleChangeTitle}
/>
<MarkdownEditor onChange={this.handleChangeContent} />
</div>
)}
{error && (
<Alert
type="error"
message="Failed to post update proposal"
description={error}
showIcon
/>
)}
</div>
</Modal>
);
}
private handleChangeTitle = (ev: React.ChangeEvent<HTMLInputElement>) => {
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? Youll 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);

View File

@ -0,0 +1,13 @@
@import '~styles/variables.less';
.UpdateModal {
&-form {
&-title {
margin-bottom: 1rem;
}
}
.ant-alert {
margin-top: 1rem;
}
}

View File

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

View File

@ -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<Props> {
render() {
const { update, goBack } = this.props;
return (
<div className="FullUpdate">
<a className="FullUpdate-back" onClick={goBack}>
<Icon type="arrow-left" /> Back to Updates
</a>
<h2 className="FullUpdate-title">{update.title}</h2>
<div className="FullUpdate-date">
{moment(update.dateCreated * 1000).format('MMMM Do, YYYY')}
</div>
<Markdown source={update.content} className="FullUpdate-body" />
</div>
);
}
}

View File

@ -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<Props> {
interface State {
activeUpdate: Update | null;
}
class ProposalUpdates extends React.Component<Props, State> {
state: State = {
activeUpdate: null,
};
componentDidMount() {
if (this.props.proposalId) {
this.props.fetchProposalUpdates(this.props.proposalId);
@ -44,30 +54,45 @@ class ProposalUpdates extends React.Component<Props> {
render() {
const { updates, isFetchingUpdates, updatesError } = this.props;
const { activeUpdate } = this.state;
let content = null;
if (isFetchingUpdates) {
content = <Spin />;
} else if (updatesError) {
content = (
<>
<h2>Something went wrong</h2>
<p>{updatesError}</p>
</>
<Placeholder
title="Something went wrong"
subtitle={updatesError}
/>
);
} else if (updates) {
if (updates.length) {
if (activeUpdate) {
content = (
<FullUpdate
update={activeUpdate}
goBack={() => this.setActiveUpdate(null)}
/>
);
}
else if (updates.length) {
content = updates.map(update => (
<div className="ProposalUpdates-update">
<div
key={update.updateId}
className="ProposalUpdates-update"
onClick={() => this.setActiveUpdate(update)}
>
<h3 className="ProposalUpdates-update-title">{update.title}</h3>
<div className="ProposalUpdates-update-date">
{moment(update.dateCreated * 1000).format('MMMM Do, YYYY')}
</div>
<div className="ProposalUpdates-update-body">
<Markdown source={this.truncate(update.body)} />
<Markdown source={this.truncate(update.content)} />
</div>
<div className="ProposalUpdates-update-controls">
<a className="ProposalUpdates-update-controls-button">Read more</a>
<a className="ProposalUpdates-update-controls-button">
Read more
</a>
<a className="ProposalUpdates-update-controls-button">
{update.totalComments} comments
</a>
@ -76,7 +101,10 @@ class ProposalUpdates extends React.Component<Props> {
));
} else {
content = (
<h3 className="ProposalUpdates-noUpdates">No updates have been posted yet</h3>
<Placeholder
title="No updates have been posted"
subtitle="Check back later to see updates from the team"
/>
);
}
}
@ -84,6 +112,10 @@ class ProposalUpdates extends React.Component<Props> {
return <div className="ProposalUpdates">{content}</div>;
}
private setActiveUpdate = (activeUpdate: Update | null) => {
this.setState({ activeUpdate });
};
private truncate(text: string) {
if (text.length < 250) {
return text;

View File

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

View File

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
const adminMenu = isTrustee && (
<Menu>
<Menu.Item onClick={this.openUpdateModal}>
Post an Update
</Menu.Item>
<Menu.Item
onClick={() => alert('Sorry, not yet implemented!')}
disabled={!isProposalActive}
@ -177,7 +183,6 @@ export class ProposalDetail extends React.Component<Props, State> {
<CommentsTab proposalId={proposal.proposalId} />
</Tabs.TabPane>
<Tabs.TabPane tab="Updates" key="updates" disabled={isPreview}>
<div style={{ marginTop: '1.5rem' }} />
<UpdatesTab proposalId={proposal.proposalId} />
</Tabs.TabPane>
{isContributor && (
@ -191,11 +196,18 @@ export class ProposalDetail extends React.Component<Props, State> {
</Tabs>
)}
{isTrustee && (
<CancelModal
proposal={proposal}
isVisible={isCancelOpen}
handleClose={this.closeCancelModal}
/>
<>
<UpdateModal
proposalId={proposal.proposalId}
isVisible={isUpdateOpen}
handleClose={this.closeUpdateModal}
/>
<CancelModal
proposal={proposal}
isVisible={isCancelOpen}
handleClose={this.closeCancelModal}
/>
</>
)}
</div>
);
@ -225,6 +237,9 @@ export class ProposalDetail extends React.Component<Props, State> {
}
};
private openUpdateModal = () => this.setState({ isUpdateOpen: true });
private closeUpdateModal = () => this.setState({ isUpdateOpen: false });
private openCancelModal = () => this.setState({ isCancelOpen: true });
private closeCancelModal = () => this.setState({ isCancelOpen: false });
}

View File

@ -100,7 +100,10 @@ export function fetchProposalUpdates(proposalId: ProposalWithCrowdFund['proposal
return (dispatch: Dispatch<any>) => {
dispatch({
type: types.PROPOSAL_UPDATES,
payload: getProposalUpdates(proposalId),
payload: getProposalUpdates(proposalId).then(res => ({
proposalId,
updates: res.data,
})),
});
};
}

View File

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

View File

@ -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) {

View File

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

View File

@ -55,7 +55,6 @@ export interface ProposalComments {
export interface ProposalUpdates {
proposalId: ProposalWithCrowdFund['proposalId'];
totalUpdates: number;
updates: Update[];
}

View File

@ -1,7 +1,7 @@
export interface Update {
updateId: number | string;
title: string;
body: string;
content: string;
dateCreated: number;
totalComments: number;
}