Merge branch 'develop' into comment-moderation

# Conflicts:
#	backend/grant/utils/pagination.py
#	frontend/client/components/Proposal/Comments/index.tsx
This commit is contained in:
Aaron 2019-02-18 15:36:31 -06:00
commit 1a87cadc4d
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
23 changed files with 470 additions and 270 deletions

View File

@ -4,6 +4,7 @@ import {
CONTRIBUTION_STATUSES,
PROPOSAL_ARBITER_STATUSES,
MILESTONE_STAGES,
PROPOSAL_STAGES,
} from './statuses';
export interface Filter {
@ -35,6 +36,14 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
group: 'Status',
}))
// proposal has extra filters
.concat(
PROPOSAL_STAGES.map(s => ({
id: `STAGE_${s.id}`,
display: `Stage: ${s.tagDisplay}`,
color: s.tagColor,
group: 'Stage',
})),
)
.concat(
PROPOSAL_ARBITER_STATUSES.map(s => ({
id: `ARBITER_${s.id}`,

View File

@ -4,6 +4,7 @@ import {
CONTRIBUTION_STATUS,
PROPOSAL_ARBITER_STATUS,
MILESTONE_STAGE,
PROPOSAL_STAGE,
} from 'src/types';
export interface StatusSoT<E> {
@ -92,6 +93,33 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
},
];
export const PROPOSAL_STAGES: Array<StatusSoT<PROPOSAL_STAGE>> = [
{
id: PROPOSAL_STAGE.PREVIEW,
tagDisplay: 'Preview',
tagColor: '#afd500',
hint: 'Proposal is not yet published.',
},
{
id: PROPOSAL_STAGE.FUNDING_REQUIRED,
tagDisplay: 'Funding',
tagColor: '#bebebe',
hint: 'Proposal has been published but still needs funding.',
},
{
id: PROPOSAL_STAGE.WIP,
tagDisplay: 'WIP',
tagColor: '#8d8d8d',
hint: 'Proposal is fully funded and the work is being done.',
},
{
id: PROPOSAL_STAGE.COMPLETED,
tagDisplay: 'Completed',
tagColor: '#108ee9',
hint: 'Proposal was accepted, published, funded and all funds paid out.',
},
];
export const PROPOSAL_ARBITER_STATUSES: Array<StatusSoT<PROPOSAL_ARBITER_STATUS>> = [
{
id: PROPOSAL_ARBITER_STATUS.MISSING,

View File

@ -89,7 +89,6 @@ class UserCommentSchema(ma.Schema):
proposal = ma.Nested(
"ProposalSchema",
exclude=[
"comments",
"team",
"milestones",
"content",

View File

@ -1,9 +1,13 @@
import click
import datetime
from random import randint
from math import floor
from flask.cli import with_appcontext
from .models import Proposal, db
from grant.utils.enums import ProposalStatus, Category
from grant.milestone.models import Milestone
from grant.comment.models import Comment
from grant.utils.enums import ProposalStatus, Category, ProposalStageEnum
from grant.user.models import User
@ -30,7 +34,12 @@ def create_proposal(stage, user_id, proposal_id, title, content):
def create_proposals(count):
user = User.query.filter_by().first()
for i in range(count):
if i < 5:
stage = ProposalStageEnum.FUNDING_REQUIRED
else:
stage = ProposalStageEnum.COMPLETED
p = Proposal.create(
stage=stage,
status=ProposalStatus.LIVE,
title=f'Fake Proposal #{i}',
content=f'My fake proposal content, numero {i}',
@ -43,6 +52,27 @@ def create_proposals(count):
p.date_published = datetime.datetime.now()
p.team.append(user)
db.session.add(p)
db.session.flush()
num_ms = randint(1, 9)
for j in range(num_ms):
m = Milestone(
title=f'Fake MS {j}',
content=f'Fake milestone #{j} on fake proposal #{i}!',
date_estimated=datetime.datetime.now(),
payout_percent=str(floor(1 / num_ms * 100)),
immediate_payout=j == 0,
proposal_id=p.id,
index=j
)
db.session.add(m)
for j in range(100):
c = Comment(
proposal_id=p.id,
user_id=user.id,
parent_comment_id=None,
content=f'Fake comment #{j} on fake proposal #{i}!'
)
db.session.add(c)
db.session.commit()
print(f'Added {count} LIVE fake proposals')

View File

@ -521,7 +521,6 @@ class ProposalSchema(ma.Schema):
"is_failed",
"funded",
"content",
"comments",
"updates",
"milestones",
"current_milestone",
@ -540,7 +539,6 @@ class ProposalSchema(ma.Schema):
date_published = ma.Method("get_date_published")
proposal_id = ma.Method("get_proposal_id")
comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True)
team = ma.Nested("UserSchema", many=True)
milestones = ma.Nested("MilestoneSchema", many=True)

View File

@ -59,20 +59,24 @@ def get_proposal(proposal_id):
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
@endpoint.api()
def get_proposal_comments(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
# Only pull top comments, replies will be attached to them
comments = Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None)
num_comments = Comment.query.filter_by(proposal_id=proposal_id).count()
return {
"proposalId": proposal_id,
"totalComments": num_comments,
"comments": comments_schema.dump(comments)
}
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
def get_proposal_comments(proposal_id, page, filters, search, sort):
# only using page, currently
filters_workaround = request.args.getlist('filters[]')
page = pagination.comment(
schema=comments_schema,
query=Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None),
page=page,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route("/<proposal_id>/comments", methods=["POST"])

View File

@ -1,6 +1,7 @@
import abc
from sqlalchemy import or_, and_
from grant.comment.models import Comment, comments_schema
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from grant.comment.models import Comment, comments_schema
from grant.user.models import User, users_schema
@ -85,10 +86,8 @@ class ProposalPagination(Pagination):
if status_filters:
query = query.filter(Proposal.status.in_(status_filters))
# TODO: figure out what is going to happen with stages
if stage_filters:
self._raise('stage filters not yet supported')
# query = query.filter(Proposal.stage.in_(stage_filters))
query = query.filter(Proposal.stage.in_(stage_filters))
if cat_filters:
query = query.filter(Proposal.category.in_(cat_filters))
if arbiter_filters:
@ -243,7 +242,7 @@ class CommentPagination(Pagination):
def paginate(
self,
schema: ma.Schema=users_schema,
schema: ma.Schema=comments_schema,
query: db.Query=None,
page: int=1,
filters: list=None,

View File

@ -12,6 +12,7 @@ import {
EmailSubscriptions,
RFP,
ProposalPageParams,
PageParams,
} from 'types';
import {
formatUserForPost,
@ -40,8 +41,8 @@ export function getProposal(proposalId: number | string): Promise<{ data: Propos
});
}
export function getProposalComments(proposalId: number | string) {
return axios.get(`/api/v1/proposals/${proposalId}/comments`);
export function getProposalComments(proposalId: number | string, params: PageParams) {
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
}
export function reportProposalComment(proposalId: number, commentId: number) {

View File

@ -1,13 +1,13 @@
import React, { ReactNode } from 'react';
import classnames from 'classnames';
import { Form, Input, Button, Icon, Radio, message } from 'antd';
import { Button, Form, Icon, Radio } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
import QRCode from 'qrcode.react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { formatZcashURI, formatZcashCLI } from 'utils/formatters';
import { formatZcashCLI, formatZcashURI } from 'utils/formatters';
import { ContributionWithAddresses } from 'types';
import Loader from 'components/Loader';
import './PaymentInfo.less';
import CopyInput from 'components/CopyInput';
interface Props {
contribution?: ContributionWithAddresses | Falsy;
@ -110,41 +110,3 @@ export default class PaymentInfo extends React.Component<Props, State> {
this.setState({ sendType: ev.target.value });
};
}
interface CopyInputProps {
label: string;
value: string | undefined;
className?: string;
help?: string;
isTextarea?: boolean;
}
const CopyInput: React.SFC<CopyInputProps> = ({
label,
value,
help,
className,
isTextarea,
}) => (
<Form.Item
className={classnames('CopyInput', className, isTextarea && 'is-textarea')}
label={label}
help={help}
>
{isTextarea ? (
<>
<Input.TextArea value={value} readOnly rows={3} />
<CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
<Button icon="copy" />
</CopyToClipboard>
</>
) : (
<>
<Input value={value} readOnly />
<CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
<Button icon="copy" />
</CopyToClipboard>
</>
)}
</Form.Item>
);

View File

@ -0,0 +1,44 @@
import React from 'react';
import { Button, Form, Input, message } from 'antd';
import classnames from 'classnames';
import CopyToClipboard from 'react-copy-to-clipboard';
interface CopyInputProps {
label: string;
value: string | undefined;
className?: string;
help?: string;
isTextarea?: boolean;
}
const CopyInput: React.SFC<CopyInputProps> = ({
label,
value,
help,
className,
isTextarea,
}) => (
<Form.Item
className={classnames('CopyInput', className, isTextarea && 'is-textarea')}
label={label}
help={help}
>
{isTextarea ? (
<>
<Input.TextArea value={value} readOnly rows={3} />
<CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
<Button icon="copy" />
</CopyToClipboard>
</>
) : (
<>
<Input value={value} readOnly />
<CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
<Button icon="copy" />
</CopyToClipboard>
</>
)}
</Form.Item>
);
export default CopyInput;

View File

@ -12,7 +12,7 @@ import { Input, Form, Col, Row, Button, Alert, Icon } from 'antd';
import { SOCIAL_INFO } from 'utils/social';
import { SOCIAL_SERVICE, User } from 'types';
import { UserState } from 'modules/users/reducers';
import { getCreateTeamMemberError } from 'modules/create/utils';
import { validateUserProfile } from 'modules/create/utils';
import AvatarEdit from './AvatarEdit';
import './ProfileEdit.less';
@ -85,8 +85,8 @@ class ProfileEdit extends React.PureComponent<Props, State> {
socialVerificationError,
activeSocialService,
} = this.state;
const error = getCreateTeamMemberError(fields);
const isMissingField = !fields.displayName || !fields.title || !fields.emailAddress;
const error = validateUserProfile(fields);
const isMissingField = !fields.displayName || !fields.title;
const isDisabled =
!!error ||
isMissingField ||
@ -132,18 +132,6 @@ class ProfileEdit extends React.PureComponent<Props, State> {
/>
</Form.Item>
<Form.Item>
<Input
name="emailAddress"
disabled={true}
placeholder="Email address (Required)"
type="email"
autoComplete="email"
value={fields.emailAddress}
onChange={this.handleChangeField}
/>
</Form.Item>
<Row gutter={12}>
{Object.values(SOCIAL_INFO).map(s => {
const field = fields.socialMedias.find(sm => sm.service === s.service);

View File

@ -0,0 +1,25 @@
.ProposalComments {
max-width: 780px;
&-skellie {
margin-top: 1rem;
}
&-alert {
margin-top: 1rem;
}
&-post {
margin-bottom: 2rem;
& > button {
margin-top: 0.5rem;
}
}
&-verify {
font-size: 1rem;
opacity: 0.7;
margin-bottom: 1rem;
}
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import { Button, message } from 'antd';
import { Button, message, Skeleton, Alert } from 'antd';
import { AppState } from 'store/reducers';
import { Proposal } from 'types';
import {
@ -8,26 +8,18 @@ import {
postProposalComment,
reportProposalComment,
} from 'modules/proposals/actions';
import {
getCommentsError,
getIsFetchingComments,
getProposalComments,
} from 'modules/proposals/selectors';
import { getIsVerified, getIsSignedIn } from 'modules/auth/selectors';
import Comments from 'components/Comments';
import Placeholder from 'components/Placeholder';
import Loader from 'components/Loader';
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
import './style.less';
import './index.less';
interface OwnProps {
proposalId: Proposal['proposalId'];
}
interface StateProps {
comments: ReturnType<typeof getProposalComments>;
isFetchingComments: ReturnType<typeof getIsFetchingComments>;
commentsError: ReturnType<typeof getCommentsError>;
detailComments: AppState['proposal']['detailComments'];
isPostCommentPending: AppState['proposal']['isPostCommentPending'];
postCommentError: AppState['proposal']['postCommentError'];
isVerified: ReturnType<typeof getIsVerified>;
@ -44,17 +36,19 @@ type Props = DispatchProps & OwnProps & StateProps;
interface State {
comment: string;
curtainsMatchDrapes: boolean;
}
class ProposalComments extends React.Component<Props, State> {
state: State = {
comment: '',
curtainsMatchDrapes: this.props.detailComments.parentId === this.props.proposalId,
};
private editor: MarkdownEditor | null = null;
componentDidMount() {
if (this.props.proposalId) {
if (!this.state.curtainsMatchDrapes) {
this.props.fetchProposalComments(this.props.proposalId);
}
}
@ -79,41 +73,51 @@ class ProposalComments extends React.Component<Props, State> {
}
render() {
const {
comments,
isFetchingComments,
commentsError,
isPostCommentPending,
isVerified,
isSignedIn,
} = this.props;
const { detailComments, isPostCommentPending, isVerified, isSignedIn } = this.props;
const { comment } = this.state;
let content = null;
if (isFetchingComments) {
content = <Loader />;
} else if (commentsError) {
const { hasFetched, isFetching, hasMore, pages, fetchError, total } = detailComments;
if (!hasFetched) {
content = [1, 2, 3].map(i => (
<Skeleton
className="ProposalComments-skellie"
key={i}
active
avatar={{ shape: 'square' }}
paragraph={{ rows: 2 }}
/>
));
} else if (total) {
content = (
<>
<h2>Something went wrong</h2>
<p>{commentsError}</p>
{pages.map((p, i) => (
<Comments key={i} comments={p} />
))}
<div>
{hasMore && (
<Button
onClick={() => this.props.fetchProposalComments()}
loading={isFetching}
block
>
Older Comments
</Button>
)}
</div>
</>
);
} else if (comments) {
if (comments.length) {
content = <Comments comments={comments} />;
} else {
content = (
<Placeholder
title="No comments have been made yet"
subtitle="Why not be the first?"
/>
);
}
} else {
content = (
<Placeholder
title="No comments have been made yet"
subtitle="Why not be the first?"
/>
);
}
return (
<div>
<div className="ProposalComments">
<div className="ProposalComments-post">
{isVerified && (
<>
@ -122,7 +126,6 @@ class ProposalComments extends React.Component<Props, State> {
onChange={this.handleCommentChange}
type={MARKDOWN_TYPE.REDUCED}
/>
<div style={{ marginTop: '0.5rem' }} />
<Button
onClick={this.postComment}
disabled={!comment.length}
@ -147,6 +150,14 @@ class ProposalComments extends React.Component<Props, State> {
)}
</div>
{content}
{fetchError && (
<Alert
className="ProposalComments-alert"
type="error"
message="Oopsy, there was a problem loading comments!"
description={fetchError}
/>
)}
</div>
);
}
@ -161,10 +172,8 @@ class ProposalComments extends React.Component<Props, State> {
}
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
(state, ownProps) => ({
comments: getProposalComments(state, ownProps.proposalId),
isFetchingComments: getIsFetchingComments(state),
commentsError: getCommentsError(state),
state => ({
detailComments: state.proposal.detailComments,
isPostCommentPending: state.proposal.isPostCommentPending,
postCommentError: state.proposal.postCommentError,
isVerified: getIsVerified(state),

View File

@ -1,11 +0,0 @@
.ProposalComments {
&-post {
margin-bottom: 2rem;
max-width: 780px;
}
&-verify {
font-size: 1rem;
opacity: 0.7;
margin-bottom: 1rem;
}
}

View File

@ -55,18 +55,30 @@ export default class ProposalFilters extends React.Component<Props> {
<Divider />
<h3>Proposal stage</h3>
{typedKeys(PROPOSAL_STAGE).map(s => (
<div key={s} style={{ marginBottom: '0.25rem' }}>
<Radio
value={s}
name="stage"
checked={filters.stage.includes(s as PROPOSAL_STAGE)}
onChange={this.handleStageChange}
>
{STAGE_UI[s].label}
</Radio>
</div>
))}
<div style={{ marginBottom: '0.25rem' }}>
<Radio
value="ALL"
name="stage"
checked={filters.stage.length === 0}
onChange={this.handleStageChange}
>
All
</Radio>
</div>
{typedKeys(PROPOSAL_STAGE)
.filter(s => s !== PROPOSAL_STAGE.PREVIEW) // skip this one
.map(s => (
<div key={s} style={{ marginBottom: '0.25rem' }}>
<Radio
value={s}
name="stage"
checked={filters.stage.includes(s as PROPOSAL_STAGE)}
onChange={this.handleStageChange}
>
{STAGE_UI[s].label}
</Radio>
</div>
))}
</Card>
</div>
);
@ -86,9 +98,13 @@ export default class ProposalFilters extends React.Component<Props> {
};
private handleStageChange = (ev: RadioChangeEvent) => {
let stage = [] as PROPOSAL_STAGE[];
if (ev.target.value !== 'ALL') {
stage = [ev.target.value as PROPOSAL_STAGE];
}
this.props.handleChangeFilters({
...this.props.filters,
stage: [ev.target.value as PROPOSAL_STAGE],
stage,
});
};

View File

@ -28,4 +28,19 @@
.social-mixin(facebook, @facebook-color);
.social-mixin(linkedin, @linkedin-color);
}
&-icon {
display: inline;
cursor: default;
.social-mixin(@name, @color) {
&.is-@{name} {
color: @color;
}
}
.social-mixin(twitter, @twitter-color);
.social-mixin(reddit, @reddit-color);
.social-mixin(facebook, @facebook-color);
.social-mixin(linkedin, @linkedin-color);
}
}

View File

@ -1,28 +1,35 @@
import React from 'react';
import './SocialShare.less';
import { Modal } from 'antd';
import CopyInput from 'components/CopyInput';
interface TypeOptions {
className: string;
humanName: string;
url: (url: string, title: string, text: string) => string;
}
const types: { [index: string]: TypeOptions } = {
twitter: {
className: 'fab fa-twitter-square',
humanName: 'Twitter',
className: 'fab fa-twitter',
url: (url: string, _: string, text: string) =>
`https://twitter.com/intent/tweet?url=${url}&text=${text}`,
},
reddit: {
className: 'fab fa-reddit-square',
humanName: 'Reddit',
className: 'fab fa-reddit',
url: (url: string, title: string) =>
`https://reddit.com/submit?url=${url}&title=${title}`,
},
facebook: {
className: 'fab fa-facebook-square',
humanName: 'Facebook',
className: 'fab fa-facebook',
url: (url: string) => `http://www.facebook.com/sharer.php?u=${url}`,
},
linkedin: {
className: 'fab fa-linkedin-square',
humanName: 'LinkedIn',
className: 'fab fa-linkedin',
url: (url: string, title: string, text: string) =>
`https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${title}&summary=${text}`,
},
@ -34,27 +41,58 @@ interface OwnProps {
title: string;
}
interface State {
openModal: boolean;
socialKey: string;
}
type Props = OwnProps;
export default class SocialShare extends React.Component<Props> {
export default class SocialShare extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
openModal: false,
socialKey: 'twitter',
};
}
render() {
let { url, title, text } = this.props;
const { openModal, socialKey } = this.state;
url = url.replace(`localhost:${process.env.PORT}`, 'grants.zfnd.org');
url = encodeURIComponent(url);
title = encodeURIComponent(title);
text = encodeURIComponent(text);
return (
<div className="SocialShare">
<Modal
title={
<a className={`SocialShare-icon is-${socialKey}`}>
<i className={types[socialKey].className} />
{' ' + types[socialKey].humanName}
</a>
}
visible={openModal}
footer={null}
onCancel={() => this.setState({ openModal: false })}
>
<CopyInput label={''} value={types[socialKey].url(url, title, text)} />
</Modal>
{Object.keys(types).map(key => {
const opts = types[key];
return (
<a
target="popup"
onClick={() => windowOpen(opts.url(url, title, text))}
onClick={() =>
this.setState({
openModal: true,
socialKey: key,
})
}
key={key}
className={`SocialShare-button is-${key}`}
>
<i className={opts.className} />
<i className={opts.className + '-square'} />
</a>
);
})}
@ -62,36 +100,3 @@ export default class SocialShare extends React.Component<Props> {
);
}
}
function windowOpen(url: string, name = 'Share', width = 550, height = 500) {
const left =
window.outerWidth / 2 + (window.screenX || window.screenLeft || 0) - width / 2;
const top =
window.outerHeight / 2 + (window.screenY || window.screenTop || 0) - height / 2;
const config: { [index: string]: any } = {
height,
width,
left,
top,
location: 'no',
toolbar: 'no',
status: 'no',
directories: 'no',
menubar: 'no',
scrollbars: 'yes',
resizable: 'no',
centerscreen: 'yes',
chrome: 'yes',
};
const shareDialog = window.open(
url,
name,
Object.keys(config)
.map(key => `${key}=${config[key]}`)
.join(', '),
);
return shareDialog;
}

View File

@ -127,13 +127,11 @@ export function getCreateErrors(
return errors;
}
export function getCreateTeamMemberError(user: User) {
export function validateUserProfile(user: User) {
if (user.displayName.length > 30) {
return 'Display name can only be 30 characters maximum';
} else if (user.title.length > 30) {
return 'Title can only be 30 characters maximum';
} else if (!user.emailAddress || !/.+\@.+\..+/.test(user.emailAddress)) {
return 'That doesnt look like a valid email address';
}
return '';

View File

@ -13,7 +13,7 @@ import {
import { Dispatch } from 'redux';
import { Proposal, Comment, ProposalPageParams } from 'types';
import { AppState } from 'store/reducers';
import { getProposalPageSettings } from './selectors';
import { getProposalPageSettings, getProposalCommentPageParams } from './selectors';
type GetState = () => AppState;
@ -118,12 +118,33 @@ export function fetchProposal(proposalId: Proposal['proposalId']) {
};
}
export function fetchProposalComments(proposalId: Proposal['proposalId']) {
return (dispatch: Dispatch<any>) => {
export function fetchProposalComments(id?: number) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
const state = getState();
if (!state.proposal.detail) {
return;
}
const proposalId = id || state.proposal.detail.proposalId;
dispatch({
type: types.PROPOSAL_COMMENTS,
payload: getProposalComments(proposalId),
type: types.PROPOSAL_COMMENTS_PENDING,
payload: {
parentId: proposalId, // payload gets the proposalId
},
});
// get fresh params after PENDING has run, above
const params = getProposalCommentPageParams(getState());
try {
const comments = (await getProposalComments(proposalId, params)).data;
return dispatch({
type: types.PROPOSAL_COMMENTS_FULFILLED,
payload: comments,
});
} catch (error) {
dispatch({
type: types.PROPOSAL_COMMENTS_REJECTED,
payload: error,
});
}
};
}

View File

@ -1,12 +1,13 @@
import types from './types';
import { findComment } from 'utils/helpers';
import { cloneDeep } from 'lodash';
import { pendingMoreablePage, fulfilledMoreablePage } from 'utils/helpers';
import {
Proposal,
ProposalComments,
ProposalUpdates,
Comment,
ProposalContributions,
LoadableProposalPage,
Moreable,
} from 'types';
import { PROPOSAL_SORT } from 'api/constants';
@ -26,9 +27,7 @@ export interface ProposalState {
isFetchingDetail: boolean;
detailError: null | string;
proposalComments: { [id: string]: ProposalComments };
commentsError: null | string;
isFetchingComments: boolean;
detailComments: Moreable<Comment>;
proposalUpdates: { [id: string]: ProposalUpdates };
updatesError: null | string;
@ -76,9 +75,21 @@ export const INITIAL_STATE: ProposalState = {
isFetchingDetail: false,
detailError: null,
proposalComments: {},
commentsError: null,
isFetchingComments: false,
detailComments: {
pages: [],
hasMore: false,
page: 1,
pageSize: 0,
total: 0,
search: '',
sort: '',
filters: [],
isFetching: false,
hasFetched: false,
fetchError: '',
fetchTime: 0,
parentId: null,
},
proposalUpdates: {},
updatesError: null,
@ -95,17 +106,6 @@ export const INITIAL_STATE: ProposalState = {
deleteContributionError: null,
};
function addComments(state: ProposalState, payload: { data: ProposalComments }) {
return {
...state,
proposalComments: {
...state.proposalComments,
[payload.data.proposalId]: payload.data,
},
isFetchingComments: false,
};
}
function addUpdates(state: ProposalState, payload: ProposalUpdates) {
return {
...state,
@ -134,36 +134,40 @@ interface PostCommentPayload {
parentCommentId?: Comment['id'];
}
function addPostedComment(state: ProposalState, payload: PostCommentPayload) {
const { proposalId, comment, parentCommentId } = payload;
const newComments = state.proposalComments[proposalId]
? {
...state.proposalComments[proposalId],
totalComments: state.proposalComments[proposalId].totalComments + 1,
comments: [...state.proposalComments[proposalId].comments],
}
: {
proposalId: payload.proposalId,
totalComments: 1,
comments: [],
};
if (parentCommentId) {
const parentComment = findComment(parentCommentId, newComments.comments);
if (parentComment) {
// FIXME: Object mutation because I'm lazy, but this probably shouldnt
// exist once API hookup is done. We'll just re-request from server.
parentComment.replies.unshift(comment);
const { comment, parentCommentId } = payload;
// clone so we can mutate with great abandon!
const pages = cloneDeep(state.detailComments.pages);
if (!parentCommentId) {
// its a new comment, pop it into the very first position
if (pages[0]) {
pages[0].unshift(comment);
} else {
pages[0] = [comment];
}
} else {
newComments.comments.unshift(comment);
// recursive populate replies for nested comment
const f = (id: number, p: Comment) => {
if (p.id === id) {
p.replies.unshift(comment);
return;
} else {
p.replies.forEach(x => f(id, x));
}
};
// pages > page > comments
pages.forEach(p =>
p.forEach(c => {
f(parentCommentId, c);
}),
);
}
return {
...state,
isPostCommentPending: false,
proposalComments: {
...state.proposalComments,
[payload.proposalId]: newComments,
detailComments: {
...state.detailComments,
pages,
total: state.detailComments.total + 1,
},
};
}
@ -314,17 +318,22 @@ export default (state = INITIAL_STATE, action: any) => {
case types.PROPOSAL_COMMENTS_PENDING:
return {
...state,
commentsError: null,
isFetchingComments: true,
detailComments: pendingMoreablePage(state.detailComments, payload),
};
case types.PROPOSAL_COMMENTS_FULFILLED:
return addComments(state, payload);
return {
...state,
detailComments: fulfilledMoreablePage(state.detailComments, payload),
};
case types.PROPOSAL_COMMENTS_REJECTED:
return {
...state,
// TODO: Get action to send real error
commentsError: 'Failed to fetch comments',
isFetchingComments: false,
detailComments: {
...state.detailComments,
hasFetched: true,
isFetching: false,
fetchError: (payload && payload.message) || payload.toString(),
},
};
case types.PROPOSAL_UPDATES_PENDING:

View File

@ -1,36 +1,12 @@
import { AppState } from 'store/reducers';
import {
Proposal,
ProposalComments,
ProposalUpdates,
ProposalContributions,
ProposalPageParams,
PageParams,
} from 'types';
export function getProposalComments(
state: AppState,
proposalId: Proposal['proposalId'],
): ProposalComments['comments'] | null {
const pc = state.proposal.proposalComments[proposalId];
return pc ? pc.comments : null;
}
export function getProposalCommentCount(
state: AppState,
proposalId: Proposal['proposalId'],
): ProposalComments['totalComments'] | null {
const pc = state.proposal.proposalComments[proposalId];
return pc ? pc.totalComments : null;
}
export function getIsFetchingComments(state: AppState) {
return state.proposal.isFetchingComments;
}
export function getCommentsError(state: AppState) {
return state.proposal.commentsError;
}
export function getProposalUpdates(
state: AppState,
proposalId: Proposal['proposalId'],
@ -80,3 +56,13 @@ export function getProposalPageSettings(state: AppState): ProposalPageParams {
filters,
};
}
export function getProposalCommentPageParams(state: AppState): PageParams {
const { page, search, sort, filters } = state.proposal.detailComments;
return {
page,
search,
sort,
filters,
};
}

View File

@ -1,5 +1,5 @@
import { pick } from 'lodash';
import { Comment } from 'types';
import { Comment, Moreable, ServerPage } from 'types';
export function isNumeric(n: any) {
return !isNaN(parseFloat(n)) && isFinite(n);
@ -45,3 +45,56 @@ export function urlToPublic(url: string) {
}
return withPublicHost;
}
export function pendingMoreablePage<T>(
state: Moreable<T>,
params: Partial<Moreable<T>>,
): Moreable<T> {
let newState: Partial<Moreable<T>> = {
isFetching: true,
page: state.page + 1,
fetchError: '',
};
// if we ever use search, filter or sort we'll want to check those here
if (state.parentId !== params.parentId) {
// reset
newState = {
...newState,
parentId: params.parentId,
pages: [],
page: 1,
pageSize: 0,
total: 0,
hasFetched: false,
};
}
return {
...state,
...newState,
};
}
export function fulfilledMoreablePage<T>(
state: Moreable<T>,
serverPage: ServerPage<T>,
): Moreable<T> {
const { total, pageSize, page, items } = serverPage;
let pages = [...state.pages, items];
if (page !== state.pages.length + 1) {
if (page === 1) {
pages = [items];
}
}
const hasMore = page * pageSize < total;
return {
...state,
total,
pageSize,
page,
pages,
hasMore,
hasFetched: true,
isFetching: false,
fetchTime: Date.now(),
};
}

View File

@ -10,6 +10,10 @@ export interface Page {
filters: string[];
}
export interface ServerPage<T> extends Page {
items: T[];
}
export type PageParams = Omit<Page, 'pageSize' | 'total'>;
export interface Loadable {
@ -28,6 +32,14 @@ export interface ProposalPage extends Omit<Page, 'filters' | 'sort'> {
};
}
export type LoadablePage = Page & Loadable;
export type LoadableProposalPage = ProposalPage & Loadable;
export type ProposalPageParams = Omit<ProposalPage, 'items' | 'pageSize' | 'total'>;
export interface Moreable<T> extends LoadablePage {
pages: T[][]; // ex: Comment
hasMore: boolean;
parentId: null | number; // ex: proposalId, parentCommentId... (optional)
}