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:
parent
cdc3ea0107
commit
118d7b645e
|
@ -27,6 +27,22 @@ proposal_team = db.Table(
|
||||||
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
|
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):
|
class Proposal(db.Model):
|
||||||
__tablename__ = "proposal"
|
__tablename__ = "proposal"
|
||||||
|
@ -42,6 +58,7 @@ class Proposal(db.Model):
|
||||||
|
|
||||||
team = db.relationship("User", secondary=proposal_team)
|
team = db.relationship("User", secondary=proposal_team)
|
||||||
comments = db.relationship(Comment, backref="proposal", lazy=True)
|
comments = db.relationship(Comment, backref="proposal", lazy=True)
|
||||||
|
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True)
|
||||||
milestones = db.relationship("Milestone", backref="proposal", lazy=True)
|
milestones = db.relationship("Milestone", backref="proposal", lazy=True)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@ -90,6 +107,7 @@ class ProposalSchema(ma.Schema):
|
||||||
"proposal_id",
|
"proposal_id",
|
||||||
"body",
|
"body",
|
||||||
"comments",
|
"comments",
|
||||||
|
"updates",
|
||||||
"milestones",
|
"milestones",
|
||||||
"category",
|
"category",
|
||||||
"team"
|
"team"
|
||||||
|
@ -100,6 +118,7 @@ class ProposalSchema(ma.Schema):
|
||||||
body = ma.Method("get_body")
|
body = ma.Method("get_body")
|
||||||
|
|
||||||
comments = ma.Nested("CommentSchema", many=True)
|
comments = ma.Nested("CommentSchema", many=True)
|
||||||
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||||
team = ma.Nested("UserSchema", many=True)
|
team = ma.Nested("UserSchema", many=True)
|
||||||
milestones = ma.Nested("MilestoneSchema", many=True)
|
milestones = ma.Nested("MilestoneSchema", many=True)
|
||||||
|
|
||||||
|
@ -115,3 +134,28 @@ class ProposalSchema(ma.Schema):
|
||||||
|
|
||||||
proposal_schema = ProposalSchema()
|
proposal_schema = ProposalSchema()
|
||||||
proposals_schema = ProposalSchema(many=True)
|
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)
|
|
@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError
|
||||||
from grant.comment.models import Comment, comment_schema
|
from grant.comment.models import Comment, comment_schema
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from grant.user.models import User, SocialMedia, Avatar
|
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")
|
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)
|
results = proposal_schema.dump(proposal)
|
||||||
return results, 201
|
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
|
||||||
|
|
|
@ -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 ###
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from './axios';
|
import axios from './axios';
|
||||||
import { Proposal, TeamMember } from 'types';
|
import { Proposal, TeamMember, Update } from 'types';
|
||||||
import { formatTeamMemberForPost, formatTeamMemberFromGet } from 'utils/api';
|
import { formatTeamMemberForPost, formatTeamMemberFromGet } from 'utils/api';
|
||||||
import { PROPOSAL_CATEGORY } from './constants';
|
import { PROPOSAL_CATEGORY } from './constants';
|
||||||
|
|
||||||
|
@ -76,4 +76,16 @@ export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> {
|
||||||
|
|
||||||
export function verifyEmail(code: string): Promise<any> {
|
export function verifyEmail(code: string): Promise<any> {
|
||||||
return axios.post(`/api/v1/email/${code}/verify`);
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import classnames from 'classnames';
|
||||||
import { convert, MARKDOWN_TYPE } from 'utils/markdown';
|
import { convert, MARKDOWN_TYPE } from 'utils/markdown';
|
||||||
import './Markdown.less';
|
import './Markdown.less';
|
||||||
|
|
||||||
|
@ -15,8 +16,8 @@ export default class Markdown extends React.PureComponent<Props> {
|
||||||
const divProps = rest as any;
|
const divProps = rest as any;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Markdown"
|
|
||||||
{...divProps}
|
{...divProps}
|
||||||
|
className={classnames('Markdown', divProps.className)}
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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? 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);
|
|
@ -0,0 +1,13 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
.UpdateModal {
|
||||||
|
&-form {
|
||||||
|
&-title {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-alert {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,8 +3,10 @@ import { connect } from 'react-redux';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import Placeholder from 'components/Placeholder';
|
||||||
|
import FullUpdate from './FullUpdate';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { ProposalWithCrowdFund } from 'types';
|
import { ProposalWithCrowdFund, Update } from 'types';
|
||||||
import { fetchProposalUpdates } from 'modules/proposals/actions';
|
import { fetchProposalUpdates } from 'modules/proposals/actions';
|
||||||
import {
|
import {
|
||||||
getProposalUpdates,
|
getProposalUpdates,
|
||||||
|
@ -29,7 +31,15 @@ interface DispatchProps {
|
||||||
|
|
||||||
type Props = DispatchProps & OwnProps & StateProps;
|
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() {
|
componentDidMount() {
|
||||||
if (this.props.proposalId) {
|
if (this.props.proposalId) {
|
||||||
this.props.fetchProposalUpdates(this.props.proposalId);
|
this.props.fetchProposalUpdates(this.props.proposalId);
|
||||||
|
@ -44,30 +54,45 @@ class ProposalUpdates extends React.Component<Props> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { updates, isFetchingUpdates, updatesError } = this.props;
|
const { updates, isFetchingUpdates, updatesError } = this.props;
|
||||||
|
const { activeUpdate } = this.state;
|
||||||
let content = null;
|
let content = null;
|
||||||
|
|
||||||
if (isFetchingUpdates) {
|
if (isFetchingUpdates) {
|
||||||
content = <Spin />;
|
content = <Spin />;
|
||||||
} else if (updatesError) {
|
} else if (updatesError) {
|
||||||
content = (
|
content = (
|
||||||
<>
|
<Placeholder
|
||||||
<h2>Something went wrong</h2>
|
title="Something went wrong"
|
||||||
<p>{updatesError}</p>
|
subtitle={updatesError}
|
||||||
</>
|
/>
|
||||||
);
|
);
|
||||||
} else if (updates) {
|
} else if (updates) {
|
||||||
if (updates.length) {
|
if (activeUpdate) {
|
||||||
|
content = (
|
||||||
|
<FullUpdate
|
||||||
|
update={activeUpdate}
|
||||||
|
goBack={() => this.setActiveUpdate(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (updates.length) {
|
||||||
content = updates.map(update => (
|
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>
|
<h3 className="ProposalUpdates-update-title">{update.title}</h3>
|
||||||
<div className="ProposalUpdates-update-date">
|
<div className="ProposalUpdates-update-date">
|
||||||
{moment(update.dateCreated * 1000).format('MMMM Do, YYYY')}
|
{moment(update.dateCreated * 1000).format('MMMM Do, YYYY')}
|
||||||
</div>
|
</div>
|
||||||
<div className="ProposalUpdates-update-body">
|
<div className="ProposalUpdates-update-body">
|
||||||
<Markdown source={this.truncate(update.body)} />
|
<Markdown source={this.truncate(update.content)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="ProposalUpdates-update-controls">
|
<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">
|
<a className="ProposalUpdates-update-controls-button">
|
||||||
{update.totalComments} comments
|
{update.totalComments} comments
|
||||||
</a>
|
</a>
|
||||||
|
@ -76,7 +101,10 @@ class ProposalUpdates extends React.Component<Props> {
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
content = (
|
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>;
|
return <div className="ProposalUpdates">{content}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setActiveUpdate = (activeUpdate: Update | null) => {
|
||||||
|
this.setState({ activeUpdate });
|
||||||
|
};
|
||||||
|
|
||||||
private truncate(text: string) {
|
private truncate(text: string) {
|
||||||
if (text.length < 250) {
|
if (text.length < 250) {
|
||||||
return text;
|
return text;
|
||||||
|
|
|
@ -1,21 +1,33 @@
|
||||||
.ProposalUpdates {
|
.ProposalUpdates {
|
||||||
|
padding-left: 0.2rem;
|
||||||
|
max-width: 880px;
|
||||||
|
|
||||||
&-update {
|
&-update {
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
position: relative;
|
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 {
|
&-title {
|
||||||
font-size: 2rem;
|
font-size: 1.8em;
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-date {
|
&-date {
|
||||||
font-size: 1.1rem;
|
font-size: 0.9rem;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
margin-bottom: 1.5rem;
|
margin-top: -0.2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-body {
|
&-body {
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-controls {
|
&-controls {
|
||||||
|
@ -33,7 +45,7 @@
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
background: #888;
|
background: #AAA;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child:after {
|
&:last-child:after {
|
||||||
|
@ -42,11 +54,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-noUpdates {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
line-height: 10rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import UpdatesTab from './Updates';
|
||||||
import GovernanceTab from './Governance';
|
import GovernanceTab from './Governance';
|
||||||
import ContributorsTab from './Contributors';
|
import ContributorsTab from './Contributors';
|
||||||
// import CommunityTab from './Community';
|
// import CommunityTab from './Community';
|
||||||
|
import UpdateModal from './UpdateModal';
|
||||||
import CancelModal from './CancelModal';
|
import CancelModal from './CancelModal';
|
||||||
import './style.less';
|
import './style.less';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
@ -46,6 +47,7 @@ type Props = StateProps & DispatchProps & Web3Props & OwnProps;
|
||||||
interface State {
|
interface State {
|
||||||
isBodyExpanded: boolean;
|
isBodyExpanded: boolean;
|
||||||
isBodyOverflowing: boolean;
|
isBodyOverflowing: boolean;
|
||||||
|
isUpdateOpen: boolean;
|
||||||
isCancelOpen: boolean;
|
isCancelOpen: boolean;
|
||||||
bodyId: string;
|
bodyId: string;
|
||||||
}
|
}
|
||||||
|
@ -54,6 +56,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
state: State = {
|
state: State = {
|
||||||
isBodyExpanded: false,
|
isBodyExpanded: false,
|
||||||
isBodyOverflowing: false,
|
isBodyOverflowing: false,
|
||||||
|
isUpdateOpen: false,
|
||||||
isCancelOpen: false,
|
isCancelOpen: false,
|
||||||
bodyId: `body-${Math.floor(Math.random() * 1000000)}`,
|
bodyId: `body-${Math.floor(Math.random() * 1000000)}`,
|
||||||
};
|
};
|
||||||
|
@ -79,7 +82,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { proposal, isPreview, account } = this.props;
|
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;
|
const showExpand = !isBodyExpanded && isBodyOverflowing;
|
||||||
|
|
||||||
if (!proposal) {
|
if (!proposal) {
|
||||||
|
@ -94,6 +97,9 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
|
|
||||||
const adminMenu = isTrustee && (
|
const adminMenu = isTrustee && (
|
||||||
<Menu>
|
<Menu>
|
||||||
|
<Menu.Item onClick={this.openUpdateModal}>
|
||||||
|
Post an Update
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => alert('Sorry, not yet implemented!')}
|
onClick={() => alert('Sorry, not yet implemented!')}
|
||||||
disabled={!isProposalActive}
|
disabled={!isProposalActive}
|
||||||
|
@ -177,7 +183,6 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
<CommentsTab proposalId={proposal.proposalId} />
|
<CommentsTab proposalId={proposal.proposalId} />
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane tab="Updates" key="updates" disabled={isPreview}>
|
<Tabs.TabPane tab="Updates" key="updates" disabled={isPreview}>
|
||||||
<div style={{ marginTop: '1.5rem' }} />
|
|
||||||
<UpdatesTab proposalId={proposal.proposalId} />
|
<UpdatesTab proposalId={proposal.proposalId} />
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
{isContributor && (
|
{isContributor && (
|
||||||
|
@ -191,11 +196,18 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
{isTrustee && (
|
{isTrustee && (
|
||||||
<CancelModal
|
<>
|
||||||
proposal={proposal}
|
<UpdateModal
|
||||||
isVisible={isCancelOpen}
|
proposalId={proposal.proposalId}
|
||||||
handleClose={this.closeCancelModal}
|
isVisible={isUpdateOpen}
|
||||||
/>
|
handleClose={this.closeUpdateModal}
|
||||||
|
/>
|
||||||
|
<CancelModal
|
||||||
|
proposal={proposal}
|
||||||
|
isVisible={isCancelOpen}
|
||||||
|
handleClose={this.closeCancelModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 openCancelModal = () => this.setState({ isCancelOpen: true });
|
||||||
private closeCancelModal = () => this.setState({ isCancelOpen: false });
|
private closeCancelModal = () => this.setState({ isCancelOpen: false });
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,7 +100,10 @@ export function fetchProposalUpdates(proposalId: ProposalWithCrowdFund['proposal
|
||||||
return (dispatch: Dispatch<any>) => {
|
return (dispatch: Dispatch<any>) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.PROPOSAL_UPDATES,
|
type: types.PROPOSAL_UPDATES,
|
||||||
payload: getProposalUpdates(proposalId),
|
payload: getProposalUpdates(proposalId).then(res => ({
|
||||||
|
proposalId,
|
||||||
|
updates: res.data,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
proposalUpdates: {
|
proposalUpdates: {
|
||||||
...state.proposalUpdates,
|
...state.proposalUpdates,
|
||||||
[payload.data.proposalId]: payload.data,
|
[payload.proposalId]: payload,
|
||||||
},
|
},
|
||||||
isFetchingUpdates: false,
|
isFetchingUpdates: false,
|
||||||
};
|
};
|
||||||
|
|
|
@ -51,9 +51,9 @@ export function getProposalUpdates(
|
||||||
export function getProposalUpdateCount(
|
export function getProposalUpdateCount(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
proposalId: ProposalWithCrowdFund['proposalId'],
|
proposalId: ProposalWithCrowdFund['proposalId'],
|
||||||
): ProposalUpdates['totalUpdates'] | null {
|
): number | null {
|
||||||
const pu = state.proposal.proposalUpdates[proposalId];
|
const pu = state.proposal.proposalUpdates[proposalId];
|
||||||
return pu ? pu.totalUpdates : null;
|
return pu ? pu.updates.length : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIsFetchingUpdates(state: AppState) {
|
export function getIsFetchingUpdates(state: AppState) {
|
||||||
|
|
|
@ -134,7 +134,7 @@
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
padding: 0 0 0 0.5rem;
|
padding: 0 0 0 1rem;
|
||||||
color: #777;
|
color: #777;
|
||||||
border-left: 4px solid rgba(0, 0, 0, 0.08);
|
border-left: 4px solid rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
|
|
@ -55,7 +55,6 @@ export interface ProposalComments {
|
||||||
|
|
||||||
export interface ProposalUpdates {
|
export interface ProposalUpdates {
|
||||||
proposalId: ProposalWithCrowdFund['proposalId'];
|
proposalId: ProposalWithCrowdFund['proposalId'];
|
||||||
totalUpdates: number;
|
|
||||||
updates: Update[];
|
updates: Update[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
export interface Update {
|
export interface Update {
|
||||||
updateId: number | string;
|
updateId: number | string;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
content: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
totalComments: number;
|
totalComments: number;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue