paginate comments & "All" stages filter in frontend

This commit is contained in:
Aaron 2019-02-16 19:33:57 -06:00
parent 4ce3ea4dbe
commit da889a4f58
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
15 changed files with 362 additions and 166 deletions

View File

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

View File

@ -1,8 +1,12 @@
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.milestone.models import Milestone
from grant.comment.models import Comment
from grant.utils.enums import ProposalStatus, Category
from grant.user.models import User
@ -43,6 +47,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

@ -516,7 +516,6 @@ class ProposalSchema(ma.Schema):
"is_failed",
"funded",
"content",
"comments",
"updates",
"milestones",
"current_milestone",
@ -535,7 +534,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.user.models import User, users_schema
from grant.milestone.models import Milestone
@ -229,8 +230,56 @@ class UserPagination(Pagination):
}
class CommentPagination(Pagination):
def __init__(self):
self.FILTERS = []
self.PAGE_SIZE = 10
self.SORT_MAP = {
'CREATED:DESC': Comment.date_created.desc(),
'CREATED:ASC': Comment.date_created,
}
def paginate(
self,
schema: ma.Schema=comments_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='CREATED:DESC',
):
query = query or Comment.query
sort = sort or 'CREATED:DESC'
# FILTER
if filters:
pass
# SORT (see self.SORT_MAP)
if sort:
self.validate_sort(sort)
query = query.order_by(self.SORT_MAP[sort])
# SEARCH can match txids or amounts
if search:
query = query.filter(or_(
Comment.content.ilike(f'%{search}%'),
))
res = query.paginate(page, self.PAGE_SIZE, False)
return {
'page': res.page,
'total': res.total,
'page_size': self.PAGE_SIZE,
'items': schema.dump(res.items),
'filters': filters,
'search': search,
'sort': sort
}
# expose pagination methods here
proposal = ProposalPagination().paginate
contribution = ContributionPagination().paginate
# comment = CommentPagination().paginate
comment = CommentPagination().paginate
user = UserPagination().paginate

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 getProposalUpdates(proposalId: number | string) {

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,29 +1,21 @@
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 { fetchProposalComments, postProposalComment } 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>;
@ -39,17 +31,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);
}
}
@ -74,41 +68,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 && (
<>
@ -117,7 +121,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}
@ -142,6 +145,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>
);
}
@ -156,10 +167,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

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