diff --git a/backend/grant/comment/models.py b/backend/grant/comment/models.py index f4509e51..f50c1d4c 100644 --- a/backend/grant/comment/models.py +++ b/backend/grant/comment/models.py @@ -74,7 +74,6 @@ class UserCommentSchema(ma.Schema): proposal = ma.Nested( "ProposalSchema", exclude=[ - "comments", "team", "milestones", "content", diff --git a/backend/grant/proposal/commands.py b/backend/grant/proposal/commands.py index 719b298a..b1f72426 100644 --- a/backend/grant/proposal/commands.py +++ b/backend/grant/proposal/commands.py @@ -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') diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 2504a8d1..b8729e04 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 12543932..1075d0e8 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -59,20 +59,24 @@ def get_proposal(proposal_id): @blueprint.route("//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("//comments", methods=["POST"]) diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index 58d78562..a2ee2eab 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -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 diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index e4abf96c..45320df1 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -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) { diff --git a/frontend/client/components/Proposal/Comments/index.less b/frontend/client/components/Proposal/Comments/index.less new file mode 100644 index 00000000..904bd043 --- /dev/null +++ b/frontend/client/components/Proposal/Comments/index.less @@ -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; + } +} diff --git a/frontend/client/components/Proposal/Comments/index.tsx b/frontend/client/components/Proposal/Comments/index.tsx index dcdfac15..d5e1a300 100644 --- a/frontend/client/components/Proposal/Comments/index.tsx +++ b/frontend/client/components/Proposal/Comments/index.tsx @@ -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; - isFetchingComments: ReturnType; - commentsError: ReturnType; + detailComments: AppState['proposal']['detailComments']; isPostCommentPending: AppState['proposal']['isPostCommentPending']; postCommentError: AppState['proposal']['postCommentError']; isVerified: ReturnType; @@ -39,17 +31,19 @@ type Props = DispatchProps & OwnProps & StateProps; interface State { comment: string; + curtainsMatchDrapes: boolean; } class ProposalComments extends React.Component { 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 { } 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 = ; - } else if (commentsError) { + const { hasFetched, isFetching, hasMore, pages, fetchError, total } = detailComments; + if (!hasFetched) { + content = [1, 2, 3].map(i => ( + + )); + } else if (total) { content = ( <> -

Something went wrong

-

{commentsError}

+ {pages.map((p, i) => ( + + ))} +
+ {hasMore && ( + + )} +
); - } else if (comments) { - if (comments.length) { - content = ; - } else { - content = ( - - ); - } + } else { + content = ( + + ); } return ( -
+
{isVerified && ( <> @@ -117,7 +121,6 @@ class ProposalComments extends React.Component { onChange={this.handleCommentChange} type={MARKDOWN_TYPE.REDUCED} /> -
{content} + {fetchError && ( + + )}
); } @@ -156,10 +167,8 @@ class ProposalComments extends React.Component { } export default connect( - (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), diff --git a/frontend/client/components/Proposal/Comments/style.less b/frontend/client/components/Proposal/Comments/style.less deleted file mode 100644 index 1b1b5f84..00000000 --- a/frontend/client/components/Proposal/Comments/style.less +++ /dev/null @@ -1,11 +0,0 @@ -.ProposalComments { - &-post { - margin-bottom: 2rem; - max-width: 780px; - } - &-verify { - font-size: 1rem; - opacity: 0.7; - margin-bottom: 1rem; - } -} diff --git a/frontend/client/components/Proposals/Filters/index.tsx b/frontend/client/components/Proposals/Filters/index.tsx index 494180bc..aa3689ea 100644 --- a/frontend/client/components/Proposals/Filters/index.tsx +++ b/frontend/client/components/Proposals/Filters/index.tsx @@ -55,18 +55,30 @@ export default class ProposalFilters extends React.Component {

Proposal stage

- {typedKeys(PROPOSAL_STAGE).map(s => ( -
- - {STAGE_UI[s].label} - -
- ))} +
+ + All + +
+ {typedKeys(PROPOSAL_STAGE) + .filter(s => s !== PROPOSAL_STAGE.PREVIEW) // skip this one + .map(s => ( +
+ + {STAGE_UI[s].label} + +
+ ))}
); @@ -86,9 +98,13 @@ export default class ProposalFilters extends React.Component { }; 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, }); }; diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 02e083c5..7817bcc3 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -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) => { +export function fetchProposalComments(id?: number) { + return async (dispatch: Dispatch, 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, + }); + } }; } diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index df14a1fd..709522c7 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -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; 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 shouldn’t - // 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: diff --git a/frontend/client/modules/proposals/selectors.tsx b/frontend/client/modules/proposals/selectors.tsx index 959b74ba..d4513375 100644 --- a/frontend/client/modules/proposals/selectors.tsx +++ b/frontend/client/modules/proposals/selectors.tsx @@ -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, + }; +} diff --git a/frontend/client/utils/helpers.ts b/frontend/client/utils/helpers.ts index e120843b..c84ace8b 100644 --- a/frontend/client/utils/helpers.ts +++ b/frontend/client/utils/helpers.ts @@ -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( + state: Moreable, + params: Partial>, +): Moreable { + let newState: Partial> = { + 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( + state: Moreable, + serverPage: ServerPage, +): Moreable { + 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(), + }; +} diff --git a/frontend/types/pagination.ts b/frontend/types/pagination.ts index a98af6df..722e30c5 100644 --- a/frontend/types/pagination.ts +++ b/frontend/types/pagination.ts @@ -10,6 +10,10 @@ export interface Page { filters: string[]; } +export interface ServerPage extends Page { + items: T[]; +} + export type PageParams = Omit; export interface Loadable { @@ -28,6 +32,14 @@ export interface ProposalPage extends Omit { }; } +export type LoadablePage = Page & Loadable; + export type LoadableProposalPage = ProposalPage & Loadable; export type ProposalPageParams = Omit; + +export interface Moreable extends LoadablePage { + pages: T[][]; // ex: Comment + hasMore: boolean; + parentId: null | number; // ex: proposalId, parentCommentId... (optional) +}