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( proposal = ma.Nested(
"ProposalSchema", "ProposalSchema",
exclude=[ exclude=[
"comments",
"team", "team",
"milestones", "milestones",
"content", "content",

View File

@ -1,8 +1,12 @@
import click import click
import datetime import datetime
from random import randint
from math import floor
from flask.cli import with_appcontext from flask.cli import with_appcontext
from .models import Proposal, db 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.utils.enums import ProposalStatus, Category
from grant.user.models import User from grant.user.models import User
@ -43,6 +47,27 @@ def create_proposals(count):
p.date_published = datetime.datetime.now() p.date_published = datetime.datetime.now()
p.team.append(user) p.team.append(user)
db.session.add(p) 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() db.session.commit()
print(f'Added {count} LIVE fake proposals') print(f'Added {count} LIVE fake proposals')

View File

@ -516,7 +516,6 @@ class ProposalSchema(ma.Schema):
"is_failed", "is_failed",
"funded", "funded",
"content", "content",
"comments",
"updates", "updates",
"milestones", "milestones",
"current_milestone", "current_milestone",
@ -535,7 +534,6 @@ class ProposalSchema(ma.Schema):
date_published = ma.Method("get_date_published") date_published = ma.Method("get_date_published")
proposal_id = ma.Method("get_proposal_id") proposal_id = ma.Method("get_proposal_id")
comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", 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)

View File

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

View File

@ -1,6 +1,7 @@
import abc import abc
from sqlalchemy import or_, and_ 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.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from grant.user.models import User, users_schema from grant.user.models import User, users_schema
from grant.milestone.models import Milestone 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 # expose pagination methods here
proposal = ProposalPagination().paginate proposal = ProposalPagination().paginate
contribution = ContributionPagination().paginate contribution = ContributionPagination().paginate
# comment = CommentPagination().paginate comment = CommentPagination().paginate
user = UserPagination().paginate user = UserPagination().paginate

View File

@ -12,6 +12,7 @@ import {
EmailSubscriptions, EmailSubscriptions,
RFP, RFP,
ProposalPageParams, ProposalPageParams,
PageParams,
} from 'types'; } from 'types';
import { import {
formatUserForPost, formatUserForPost,
@ -40,8 +41,8 @@ export function getProposal(proposalId: number | string): Promise<{ data: Propos
}); });
} }
export function getProposalComments(proposalId: number | string) { export function getProposalComments(proposalId: number | string, params: PageParams) {
return axios.get(`/api/v1/proposals/${proposalId}/comments`); return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
} }
export function getProposalUpdates(proposalId: number | string) { 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 React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Button, message } from 'antd'; import { Button, message, Skeleton, Alert } from 'antd';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { Proposal } from 'types'; import { Proposal } from 'types';
import { fetchProposalComments, postProposalComment } from 'modules/proposals/actions'; import { fetchProposalComments, postProposalComment } from 'modules/proposals/actions';
import {
getCommentsError,
getIsFetchingComments,
getProposalComments,
} from 'modules/proposals/selectors';
import { getIsVerified, getIsSignedIn } from 'modules/auth/selectors'; import { getIsVerified, getIsSignedIn } from 'modules/auth/selectors';
import Comments from 'components/Comments'; import Comments from 'components/Comments';
import Placeholder from 'components/Placeholder'; import Placeholder from 'components/Placeholder';
import Loader from 'components/Loader';
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor'; import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
import './style.less'; import './index.less';
interface OwnProps { interface OwnProps {
proposalId: Proposal['proposalId']; proposalId: Proposal['proposalId'];
} }
interface StateProps { interface StateProps {
comments: ReturnType<typeof getProposalComments>; detailComments: AppState['proposal']['detailComments'];
isFetchingComments: ReturnType<typeof getIsFetchingComments>;
commentsError: ReturnType<typeof getCommentsError>;
isPostCommentPending: AppState['proposal']['isPostCommentPending']; isPostCommentPending: AppState['proposal']['isPostCommentPending'];
postCommentError: AppState['proposal']['postCommentError']; postCommentError: AppState['proposal']['postCommentError'];
isVerified: ReturnType<typeof getIsVerified>; isVerified: ReturnType<typeof getIsVerified>;
@ -39,17 +31,19 @@ type Props = DispatchProps & OwnProps & StateProps;
interface State { interface State {
comment: string; comment: string;
curtainsMatchDrapes: boolean;
} }
class ProposalComments extends React.Component<Props, State> { class ProposalComments extends React.Component<Props, State> {
state: State = { state: State = {
comment: '', comment: '',
curtainsMatchDrapes: this.props.detailComments.parentId === this.props.proposalId,
}; };
private editor: MarkdownEditor | null = null; private editor: MarkdownEditor | null = null;
componentDidMount() { componentDidMount() {
if (this.props.proposalId) { if (!this.state.curtainsMatchDrapes) {
this.props.fetchProposalComments(this.props.proposalId); this.props.fetchProposalComments(this.props.proposalId);
} }
} }
@ -74,29 +68,40 @@ class ProposalComments extends React.Component<Props, State> {
} }
render() { render() {
const { const { detailComments, isPostCommentPending, isVerified, isSignedIn } = this.props;
comments,
isFetchingComments,
commentsError,
isPostCommentPending,
isVerified,
isSignedIn,
} = this.props;
const { comment } = this.state; const { comment } = this.state;
let content = null; let content = null;
if (isFetchingComments) { const { hasFetched, isFetching, hasMore, pages, fetchError, total } = detailComments;
content = <Loader />; if (!hasFetched) {
} else if (commentsError) { content = [1, 2, 3].map(i => (
<Skeleton
className="ProposalComments-skellie"
key={i}
active
avatar={{ shape: 'square' }}
paragraph={{ rows: 2 }}
/>
));
} else if (total) {
content = ( content = (
<> <>
<h2>Something went wrong</h2> {pages.map((p, i) => (
<p>{commentsError}</p> <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 { } else {
content = ( content = (
<Placeholder <Placeholder
@ -105,10 +110,9 @@ class ProposalComments extends React.Component<Props, State> {
/> />
); );
} }
}
return ( return (
<div> <div className="ProposalComments">
<div className="ProposalComments-post"> <div className="ProposalComments-post">
{isVerified && ( {isVerified && (
<> <>
@ -117,7 +121,6 @@ class ProposalComments extends React.Component<Props, State> {
onChange={this.handleCommentChange} onChange={this.handleCommentChange}
type={MARKDOWN_TYPE.REDUCED} type={MARKDOWN_TYPE.REDUCED}
/> />
<div style={{ marginTop: '0.5rem' }} />
<Button <Button
onClick={this.postComment} onClick={this.postComment}
disabled={!comment.length} disabled={!comment.length}
@ -142,6 +145,14 @@ class ProposalComments extends React.Component<Props, State> {
)} )}
</div> </div>
{content} {content}
{fetchError && (
<Alert
className="ProposalComments-alert"
type="error"
message="Oopsy, there was a problem loading comments!"
description={fetchError}
/>
)}
</div> </div>
); );
} }
@ -156,10 +167,8 @@ class ProposalComments extends React.Component<Props, State> {
} }
export default connect<StateProps, DispatchProps, OwnProps, AppState>( export default connect<StateProps, DispatchProps, OwnProps, AppState>(
(state, ownProps) => ({ state => ({
comments: getProposalComments(state, ownProps.proposalId), detailComments: state.proposal.detailComments,
isFetchingComments: getIsFetchingComments(state),
commentsError: getCommentsError(state),
isPostCommentPending: state.proposal.isPostCommentPending, isPostCommentPending: state.proposal.isPostCommentPending,
postCommentError: state.proposal.postCommentError, postCommentError: state.proposal.postCommentError,
isVerified: getIsVerified(state), 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,7 +55,19 @@ export default class ProposalFilters extends React.Component<Props> {
<Divider /> <Divider />
<h3>Proposal stage</h3> <h3>Proposal stage</h3>
{typedKeys(PROPOSAL_STAGE).map(s => ( <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' }}> <div key={s} style={{ marginBottom: '0.25rem' }}>
<Radio <Radio
value={s} value={s}
@ -86,9 +98,13 @@ export default class ProposalFilters extends React.Component<Props> {
}; };
private handleStageChange = (ev: RadioChangeEvent) => { 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.handleChangeFilters({
...this.props.filters, ...this.props.filters,
stage: [ev.target.value as PROPOSAL_STAGE], stage,
}); });
}; };

View File

@ -13,7 +13,7 @@ import {
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { Proposal, Comment, ProposalPageParams } from 'types'; import { Proposal, Comment, ProposalPageParams } from 'types';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { getProposalPageSettings } from './selectors'; import { getProposalPageSettings, getProposalCommentPageParams } from './selectors';
type GetState = () => AppState; type GetState = () => AppState;
@ -118,12 +118,33 @@ export function fetchProposal(proposalId: Proposal['proposalId']) {
}; };
} }
export function fetchProposalComments(proposalId: Proposal['proposalId']) { export function fetchProposalComments(id?: number) {
return (dispatch: Dispatch<any>) => { return async (dispatch: Dispatch<any>, getState: GetState) => {
const state = getState();
if (!state.proposal.detail) {
return;
}
const proposalId = id || state.proposal.detail.proposalId;
dispatch({ dispatch({
type: types.PROPOSAL_COMMENTS, type: types.PROPOSAL_COMMENTS_PENDING,
payload: getProposalComments(proposalId), 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 types from './types';
import { findComment } from 'utils/helpers'; import { cloneDeep } from 'lodash';
import { pendingMoreablePage, fulfilledMoreablePage } from 'utils/helpers';
import { import {
Proposal, Proposal,
ProposalComments,
ProposalUpdates, ProposalUpdates,
Comment, Comment,
ProposalContributions, ProposalContributions,
LoadableProposalPage, LoadableProposalPage,
Moreable,
} from 'types'; } from 'types';
import { PROPOSAL_SORT } from 'api/constants'; import { PROPOSAL_SORT } from 'api/constants';
@ -26,9 +27,7 @@ export interface ProposalState {
isFetchingDetail: boolean; isFetchingDetail: boolean;
detailError: null | string; detailError: null | string;
proposalComments: { [id: string]: ProposalComments }; detailComments: Moreable<Comment>;
commentsError: null | string;
isFetchingComments: boolean;
proposalUpdates: { [id: string]: ProposalUpdates }; proposalUpdates: { [id: string]: ProposalUpdates };
updatesError: null | string; updatesError: null | string;
@ -76,9 +75,21 @@ export const INITIAL_STATE: ProposalState = {
isFetchingDetail: false, isFetchingDetail: false,
detailError: null, detailError: null,
proposalComments: {}, detailComments: {
commentsError: null, pages: [],
isFetchingComments: false, hasMore: false,
page: 1,
pageSize: 0,
total: 0,
search: '',
sort: '',
filters: [],
isFetching: false,
hasFetched: false,
fetchError: '',
fetchTime: 0,
parentId: null,
},
proposalUpdates: {}, proposalUpdates: {},
updatesError: null, updatesError: null,
@ -95,17 +106,6 @@ export const INITIAL_STATE: ProposalState = {
deleteContributionError: null, 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) { function addUpdates(state: ProposalState, payload: ProposalUpdates) {
return { return {
...state, ...state,
@ -134,36 +134,40 @@ interface PostCommentPayload {
parentCommentId?: Comment['id']; parentCommentId?: Comment['id'];
} }
function addPostedComment(state: ProposalState, payload: PostCommentPayload) { function addPostedComment(state: ProposalState, payload: PostCommentPayload) {
const { proposalId, comment, parentCommentId } = payload; const { comment, parentCommentId } = payload;
const newComments = state.proposalComments[proposalId] // clone so we can mutate with great abandon!
? { const pages = cloneDeep(state.detailComments.pages);
...state.proposalComments[proposalId], if (!parentCommentId) {
totalComments: state.proposalComments[proposalId].totalComments + 1, // its a new comment, pop it into the very first position
comments: [...state.proposalComments[proposalId].comments], if (pages[0]) {
} pages[0].unshift(comment);
: { } else {
proposalId: payload.proposalId, pages[0] = [comment];
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);
} }
} else { } 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 { return {
...state, ...state,
isPostCommentPending: false, isPostCommentPending: false,
proposalComments: { detailComments: {
...state.proposalComments, ...state.detailComments,
[payload.proposalId]: newComments, pages,
total: state.detailComments.total + 1,
}, },
}; };
} }
@ -314,17 +318,22 @@ export default (state = INITIAL_STATE, action: any) => {
case types.PROPOSAL_COMMENTS_PENDING: case types.PROPOSAL_COMMENTS_PENDING:
return { return {
...state, ...state,
commentsError: null, detailComments: pendingMoreablePage(state.detailComments, payload),
isFetchingComments: true,
}; };
case types.PROPOSAL_COMMENTS_FULFILLED: case types.PROPOSAL_COMMENTS_FULFILLED:
return addComments(state, payload); return {
...state,
detailComments: fulfilledMoreablePage(state.detailComments, payload),
};
case types.PROPOSAL_COMMENTS_REJECTED: case types.PROPOSAL_COMMENTS_REJECTED:
return { return {
...state, ...state,
// TODO: Get action to send real error detailComments: {
commentsError: 'Failed to fetch comments', ...state.detailComments,
isFetchingComments: false, hasFetched: true,
isFetching: false,
fetchError: (payload && payload.message) || payload.toString(),
},
}; };
case types.PROPOSAL_UPDATES_PENDING: case types.PROPOSAL_UPDATES_PENDING:

View File

@ -1,36 +1,12 @@
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { import {
Proposal, Proposal,
ProposalComments,
ProposalUpdates, ProposalUpdates,
ProposalContributions, ProposalContributions,
ProposalPageParams, ProposalPageParams,
PageParams,
} from 'types'; } 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( export function getProposalUpdates(
state: AppState, state: AppState,
proposalId: Proposal['proposalId'], proposalId: Proposal['proposalId'],
@ -80,3 +56,13 @@ export function getProposalPageSettings(state: AppState): ProposalPageParams {
filters, 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 { pick } from 'lodash';
import { Comment } from 'types'; import { Comment, Moreable, ServerPage } from 'types';
export function isNumeric(n: any) { export function isNumeric(n: any) {
return !isNaN(parseFloat(n)) && isFinite(n); return !isNaN(parseFloat(n)) && isFinite(n);
@ -45,3 +45,56 @@ export function urlToPublic(url: string) {
} }
return withPublicHost; 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[]; filters: string[];
} }
export interface ServerPage<T> extends Page {
items: T[];
}
export type PageParams = Omit<Page, 'pageSize' | 'total'>; export type PageParams = Omit<Page, 'pageSize' | 'total'>;
export interface Loadable { 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 LoadableProposalPage = ProposalPage & Loadable;
export type ProposalPageParams = Omit<ProposalPage, 'items' | 'pageSize' | 'total'>; 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)
}