Merge pull request #227 from grant-project/comment-moderation
Comment moderation
This commit is contained in:
commit
a4c7f25442
|
@ -18,6 +18,7 @@ import RFPDetail from 'components/RFPDetail';
|
||||||
import Contributions from 'components/Contributions';
|
import Contributions from 'components/Contributions';
|
||||||
import ContributionForm from 'components/ContributionForm';
|
import ContributionForm from 'components/ContributionForm';
|
||||||
import ContributionDetail from 'components/ContributionDetail';
|
import ContributionDetail from 'components/ContributionDetail';
|
||||||
|
import Moderation from 'components/Moderation';
|
||||||
|
|
||||||
import 'styles/style.less';
|
import 'styles/style.less';
|
||||||
|
|
||||||
|
@ -49,6 +50,7 @@ class Routes extends React.Component<Props> {
|
||||||
<Route path="/contributions/:id" component={ContributionDetail} />
|
<Route path="/contributions/:id" component={ContributionDetail} />
|
||||||
<Route path="/contributions" component={Contributions} />
|
<Route path="/contributions" component={Contributions} />
|
||||||
<Route path="/emails/:type?" component={Emails} />
|
<Route path="/emails/:type?" component={Emails} />
|
||||||
|
<Route path="/moderation" component={Moderation} />
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</Template>
|
</Template>
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
.ModerationItem {
|
||||||
|
& .ant-list-item-meta-title {
|
||||||
|
& small {
|
||||||
|
color: #868686;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,81 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import store from 'src/store';
|
||||||
|
import { Popconfirm, List, Avatar, Icon, message } from 'antd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Comment } from 'src/types';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import Markdown from '../Markdown';
|
||||||
|
import ShowMore from 'components/ShowMore';
|
||||||
|
import './ModerationItem.less';
|
||||||
|
|
||||||
|
class ModerationItem extends React.Component<Comment> {
|
||||||
|
render() {
|
||||||
|
const p = this.props;
|
||||||
|
const avatarUrl = (p.author!.avatar && p.author!.avatar!.imageUrl) || undefined;
|
||||||
|
const actions = [
|
||||||
|
<Popconfirm
|
||||||
|
key="toggleHide"
|
||||||
|
onConfirm={this.handleHide}
|
||||||
|
title={`${
|
||||||
|
p.hidden ? 'Show' : 'Hide'
|
||||||
|
} the content of this comment on public view?`}
|
||||||
|
okText={p.hidden ? 'Show' : 'Hide'}
|
||||||
|
okType="primary"
|
||||||
|
placement="left"
|
||||||
|
>
|
||||||
|
<a>{p.hidden ? 'show' : 'hide'}</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item className="ModerationItem" actions={actions}>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<Avatar icon="user" src={avatarUrl} shape="square" />}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<Link to={`/users/${p.author!.userid}`}>{p.author!.displayName}</Link>{' '}
|
||||||
|
<small>commented on</small>{' '}
|
||||||
|
<Link to={`/proposals/${p.proposalId}`}>
|
||||||
|
{p.proposal && p.proposal.title}
|
||||||
|
</Link>{' '}
|
||||||
|
<small>at {formatDateSeconds(p.dateCreated)}</small>{' '}
|
||||||
|
{p.hidden && (
|
||||||
|
<>
|
||||||
|
<Icon type="eye-invisible" />{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{p.reported && (
|
||||||
|
<>
|
||||||
|
<Icon type="flag" />{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<ShowMore height={100}>
|
||||||
|
<Markdown source={p.content} />
|
||||||
|
</ShowMore>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private handleHide = async () => {
|
||||||
|
await store.updateComment(this.props.id, { hidden: !this.props.hidden });
|
||||||
|
if (store.commentSaved) {
|
||||||
|
message.success(
|
||||||
|
<>
|
||||||
|
<b>
|
||||||
|
{this.props.author!.displayName}
|
||||||
|
's
|
||||||
|
</b>{' '}
|
||||||
|
comment on <b>{this.props.proposal!.title}</b>{' '}
|
||||||
|
{this.props.hidden ? 'hidden' : 'now visible'}
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default view(ModerationItem);
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import store from 'src/store';
|
||||||
|
import ModerationItem from './ModerationItem';
|
||||||
|
import Pageable from 'components/Pageable';
|
||||||
|
import { Comment } from 'src/types';
|
||||||
|
import { commentFilters } from 'src/util/filters';
|
||||||
|
|
||||||
|
class Moderation extends React.Component<{}> {
|
||||||
|
render() {
|
||||||
|
const { page } = store.comments;
|
||||||
|
// NOTE: sync with /backend ... pagination.py ProposalCommentPagination.SORT_MAP
|
||||||
|
const sorts = ['CREATED:DESC', 'CREATED:ASC'];
|
||||||
|
return (
|
||||||
|
<Pageable
|
||||||
|
page={page}
|
||||||
|
filters={commentFilters}
|
||||||
|
sorts={sorts}
|
||||||
|
searchPlaceholder="Search comment content"
|
||||||
|
renderItem={(p: Comment) => <ModerationItem key={p.id} {...p} />}
|
||||||
|
handleSearch={store.fetchComments}
|
||||||
|
handleChangeQuery={store.setCommentPageParams}
|
||||||
|
handleResetQuery={store.resetCommentPageParams}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default view(Moderation);
|
|
@ -10,7 +10,7 @@ import './index.less';
|
||||||
|
|
||||||
interface OwnProps<T> {
|
interface OwnProps<T> {
|
||||||
page: PageData<T>;
|
page: PageData<T>;
|
||||||
filters: Filters;
|
filters: null | Filters;
|
||||||
sorts: string[];
|
sorts: string[];
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
controlsExtra?: React.ReactNode;
|
controlsExtra?: React.ReactNode;
|
||||||
|
@ -39,7 +39,7 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const loading = !page.fetched || page.fetching;
|
const loading = !page.fetched || page.fetching;
|
||||||
|
|
||||||
const statusFilterMenu = (
|
const statusFilterMenu = filters && (
|
||||||
<Menu onClick={this.handleFilterClick}>
|
<Menu onClick={this.handleFilterClick}>
|
||||||
{filters.list.map(f => (
|
{filters.list.map(f => (
|
||||||
<Menu.Item key={f.id}>{f.display}</Menu.Item>
|
<Menu.Item key={f.id}>{f.display}</Menu.Item>
|
||||||
|
@ -63,11 +63,13 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
onSearch={this.handleSearch}
|
onSearch={this.handleSearch}
|
||||||
/>
|
/>
|
||||||
|
{filters && (
|
||||||
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
|
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
|
||||||
<Button>
|
<Button>
|
||||||
Filter <Icon type="down" />
|
Filter <Icon type="down" />
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
)}
|
||||||
<Dropdown overlay={sortMenu} trigger={['click']}>
|
<Dropdown overlay={sortMenu} trigger={['click']}>
|
||||||
<Button>
|
<Button>
|
||||||
{'Sort ' + page.sort} <Icon type="down" />
|
{'Sort ' + page.sort} <Icon type="down" />
|
||||||
|
@ -86,7 +88,8 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!!page.filters.length && (
|
{filters &&
|
||||||
|
!!page.filters.length && (
|
||||||
<div className="Pageable-filters">
|
<div className="Pageable-filters">
|
||||||
Filters:{' '}
|
Filters:{' '}
|
||||||
{page.filters.map(fId => {
|
{page.filters.map(fId => {
|
||||||
|
|
|
@ -3,7 +3,19 @@ import moment from 'moment';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Form, Input, Select, Icon, Button, message, Spin, Checkbox, Row, Col, DatePicker } from 'antd';
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Icon,
|
||||||
|
Button,
|
||||||
|
message,
|
||||||
|
Spin,
|
||||||
|
Checkbox,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
} from 'antd';
|
||||||
import Exception from 'ant-design-pro/lib/Exception';
|
import Exception from 'ant-design-pro/lib/Exception';
|
||||||
import { FormComponentProps } from 'antd/lib/form';
|
import { FormComponentProps } from 'antd/lib/form';
|
||||||
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
|
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
|
||||||
|
@ -100,7 +112,9 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
{rfpId && (
|
{rfpId && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Status"
|
label="Status"
|
||||||
help={forceClosed && 'Status is forced to "Closed" when close date is in the past'}
|
help={
|
||||||
|
forceClosed && 'Status is forced to "Closed" when close date is in the past'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{getFieldDecorator('status', {
|
{getFieldDecorator('status', {
|
||||||
initialValue: defaults.status,
|
initialValue: defaults.status,
|
||||||
|
@ -214,10 +228,10 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
help="Date that proposals will stop being submittable by"
|
help="Date that proposals will stop being submittable by"
|
||||||
>
|
>
|
||||||
{getFieldDecorator('dateCloses', {
|
{getFieldDecorator('dateCloses', {
|
||||||
initialValue: defaults.dateCloses ? moment(defaults.dateCloses * 1000) : undefined,
|
initialValue: defaults.dateCloses
|
||||||
})(
|
? moment(defaults.dateCloses * 1000)
|
||||||
<DatePicker size="large" />
|
: undefined,
|
||||||
)}
|
})(<DatePicker size="large" />)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
.ShowMore {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -0.3rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #ffffff, #ffffff);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React from 'react';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
height: number;
|
||||||
|
units?: 'px' | 'rem';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE = {
|
||||||
|
isOverflowing: false,
|
||||||
|
isShowing: false,
|
||||||
|
hasChecked: false,
|
||||||
|
};
|
||||||
|
type State = typeof STATE;
|
||||||
|
|
||||||
|
// very basic "show more" wrapper component, doesn't yet support
|
||||||
|
// - window resize height changes
|
||||||
|
// - using the parent or natural height as the desired height
|
||||||
|
export class ShowMore extends React.Component<Props, State> {
|
||||||
|
state = STATE;
|
||||||
|
wrapper: null | HTMLDivElement = null;
|
||||||
|
componentDidMount() {
|
||||||
|
this.updateOverflow();
|
||||||
|
}
|
||||||
|
componentDidUpdate(_: Props, s: State) {
|
||||||
|
const isShowingChange = s.isShowing !== this.state.isShowing;
|
||||||
|
const reset = !isShowingChange && (s.hasChecked && this.state.hasChecked);
|
||||||
|
const mustCheck = s.hasChecked && !this.state.hasChecked;
|
||||||
|
if (reset) {
|
||||||
|
this.setState(STATE);
|
||||||
|
}
|
||||||
|
if (mustCheck) {
|
||||||
|
this.updateOverflow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
const p = this.props;
|
||||||
|
const s = this.state;
|
||||||
|
const maxHeight = `${p.height}${p.units || 'px'}`;
|
||||||
|
const style = !s.hasChecked || (s.isOverflowing && !s.isShowing) ? { maxHeight } : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style} className="ShowMore" ref={d => (this.wrapper = d)}>
|
||||||
|
{p.children}
|
||||||
|
{s.isOverflowing &&
|
||||||
|
!s.isShowing && (
|
||||||
|
<div className="ShowMore-controls">
|
||||||
|
<a onClick={() => this.setState({ isShowing: true })}>show more</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.isOverflowing &&
|
||||||
|
s.isShowing && (
|
||||||
|
<div className="ShowMore-controls">
|
||||||
|
<a onClick={() => this.setState({ isShowing: false })}>hide</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private updateOverflow = () => {
|
||||||
|
const w = this.wrapper;
|
||||||
|
if (!w) return;
|
||||||
|
// natural hight is ok
|
||||||
|
if (w.clientHeight < this.props.height) {
|
||||||
|
this.setState({ isOverflowing: false, hasChecked: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// tall, let us check...
|
||||||
|
this.setState({ isOverflowing: w.scrollHeight > w.clientHeight, hasChecked: true });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShowMore;
|
|
@ -69,6 +69,12 @@ class Template extends React.Component<Props> {
|
||||||
<span className="nav-text">Emails</span>
|
<span className="nav-text">Emails</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="moderation">
|
||||||
|
<Link to="/moderation">
|
||||||
|
<Icon type="message" />
|
||||||
|
<span className="nav-text">Moderation</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item key="logout" onClick={store.logout}>
|
<Menu.Item key="logout" onClick={store.logout}>
|
||||||
<Icon type="logout" />
|
<Icon type="logout" />
|
||||||
<span className="nav-text">Logout</span>
|
<span className="nav-text">Logout</span>
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
EmailExample,
|
EmailExample,
|
||||||
PageQuery,
|
PageQuery,
|
||||||
PageData,
|
PageData,
|
||||||
|
CommentArgs,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// API
|
// API
|
||||||
|
@ -100,6 +101,16 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchComments(params: Partial<PageQuery>) {
|
||||||
|
const { data } = await api.get('/admin/comments', { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateComment(id: number, args: Partial<CommentArgs>) {
|
||||||
|
const { data } = await api.put(`/admin/comments/${id}`, args);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
async function markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
||||||
const { data } = await api.put(
|
const { data } = await api.put(
|
||||||
`/admin/proposals/${proposalId}/milestone/${milestoneId}/paid`,
|
`/admin/proposals/${proposalId}/milestone/${milestoneId}/paid`,
|
||||||
|
@ -200,6 +211,12 @@ const app = store({
|
||||||
proposalDetailApproving: false,
|
proposalDetailApproving: false,
|
||||||
proposalDetailMarkingMilestonePaid: false,
|
proposalDetailMarkingMilestonePaid: false,
|
||||||
|
|
||||||
|
comments: {
|
||||||
|
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
||||||
|
},
|
||||||
|
commentSaving: false,
|
||||||
|
commentSaved: false,
|
||||||
|
|
||||||
rfps: [] as RFP[],
|
rfps: [] as RFP[],
|
||||||
rfpsFetching: false,
|
rfpsFetching: false,
|
||||||
rfpsFetched: false,
|
rfpsFetched: false,
|
||||||
|
@ -285,40 +302,15 @@ const app = store({
|
||||||
// Users
|
// Users
|
||||||
|
|
||||||
async fetchUsers() {
|
async fetchUsers() {
|
||||||
app.users.page.fetching = true;
|
return await pageFetch(app.users, fetchUsers);
|
||||||
try {
|
|
||||||
const page = await fetchUsers(app.getUserPageQuery());
|
|
||||||
app.users.page = {
|
|
||||||
...app.users.page,
|
|
||||||
...page,
|
|
||||||
fetched: true,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
handleApiError(e);
|
|
||||||
}
|
|
||||||
app.users.page.fetching = false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getUserPageQuery() {
|
setUserPageQuery(params: Partial<PageQuery>) {
|
||||||
return pick(app.users.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
|
setPageParams(app.users, params);
|
||||||
},
|
|
||||||
|
|
||||||
setUserPageQuery(query: Partial<PageQuery>) {
|
|
||||||
// sometimes we need to reset page to 1
|
|
||||||
if (query.filters || query.search) {
|
|
||||||
query.page = 1;
|
|
||||||
}
|
|
||||||
app.users.page = {
|
|
||||||
...app.users.page,
|
|
||||||
...query,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
resetUserPageQuery() {
|
resetUserPageQuery() {
|
||||||
app.users.page.page = 1;
|
resetPageParams(app.users);
|
||||||
app.users.page.search = '';
|
|
||||||
app.users.page.sort = 'CREATED:DESC';
|
|
||||||
app.users.page.filters = [];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchUserDetail(id: number) {
|
async fetchUserDetail(id: number) {
|
||||||
|
@ -404,40 +396,15 @@ const app = store({
|
||||||
// Proposals
|
// Proposals
|
||||||
|
|
||||||
async fetchProposals() {
|
async fetchProposals() {
|
||||||
app.proposals.page.fetching = true;
|
return await pageFetch(app.proposals, fetchProposals);
|
||||||
try {
|
|
||||||
const page = await fetchProposals(app.getProposalPageQuery());
|
|
||||||
app.proposals.page = {
|
|
||||||
...app.proposals.page,
|
|
||||||
...page,
|
|
||||||
fetched: true,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
handleApiError(e);
|
|
||||||
}
|
|
||||||
app.proposals.page.fetching = false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setProposalPageQuery(query: Partial<PageQuery>) {
|
setProposalPageQuery(params: Partial<PageQuery>) {
|
||||||
// sometimes we need to reset page to 1
|
setPageParams(app.proposals, params);
|
||||||
if (query.filters || query.search) {
|
|
||||||
query.page = 1;
|
|
||||||
}
|
|
||||||
app.proposals.page = {
|
|
||||||
...app.proposals.page,
|
|
||||||
...query,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getProposalPageQuery() {
|
|
||||||
return pick(app.proposals.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
resetProposalPageQuery() {
|
resetProposalPageQuery() {
|
||||||
app.proposals.page.page = 1;
|
resetPageParams(app.proposals);
|
||||||
app.proposals.page.search = '';
|
|
||||||
app.proposals.page.sort = 'CREATED:DESC';
|
|
||||||
app.proposals.page.filters = [];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchProposalDetail(id: number) {
|
async fetchProposalDetail(id: number) {
|
||||||
|
@ -505,6 +472,33 @@ const app = store({
|
||||||
app.proposalDetailMarkingMilestonePaid = false;
|
app.proposalDetailMarkingMilestonePaid = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
|
||||||
|
async fetchComments() {
|
||||||
|
return await pageFetch(app.comments, fetchComments);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCommentPageParams(params: Partial<PageQuery>) {
|
||||||
|
setPageParams(app.comments, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetCommentPageParams() {
|
||||||
|
resetPageParams(app.comments);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateComment(id: number, args: Partial<CommentArgs>) {
|
||||||
|
app.commentSaving = true;
|
||||||
|
app.commentSaved = false;
|
||||||
|
try {
|
||||||
|
await updateComment(id, args);
|
||||||
|
app.commentSaved = true;
|
||||||
|
await app.fetchComments();
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.commentSaving = false;
|
||||||
|
},
|
||||||
|
|
||||||
// Email
|
// Email
|
||||||
|
|
||||||
async getEmailExample(type: string) {
|
async getEmailExample(type: string) {
|
||||||
|
@ -573,45 +567,15 @@ const app = store({
|
||||||
// Contributions
|
// Contributions
|
||||||
|
|
||||||
async fetchContributions() {
|
async fetchContributions() {
|
||||||
app.contributions.page.fetching = true;
|
return await pageFetch(app.contributions, getContributions);
|
||||||
try {
|
|
||||||
const page = await getContributions(app.getContributionPageQuery());
|
|
||||||
app.contributions.page = {
|
|
||||||
...app.contributions.page,
|
|
||||||
...page,
|
|
||||||
fetched: true,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
handleApiError(e);
|
|
||||||
}
|
|
||||||
app.contributions.page.fetching = false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setContributionPageQuery(query: Partial<PageQuery>) {
|
setContributionPageQuery(params: Partial<PageQuery>) {
|
||||||
// sometimes we need to reset page to 1
|
setPageParams(app.contributions, params);
|
||||||
if (query.filters || query.search) {
|
|
||||||
query.page = 1;
|
|
||||||
}
|
|
||||||
app.contributions.page = {
|
|
||||||
...app.contributions.page,
|
|
||||||
...query,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getContributionPageQuery() {
|
|
||||||
return pick(app.contributions.page, [
|
|
||||||
'page',
|
|
||||||
'search',
|
|
||||||
'filters',
|
|
||||||
'sort',
|
|
||||||
]) as PageQuery;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
resetContributionPageQuery() {
|
resetContributionPageQuery() {
|
||||||
app.contributions.page.page = 1;
|
resetPageParams(app.contributions);
|
||||||
app.contributions.page.search = '';
|
|
||||||
app.contributions.page.sort = 'CREATED:DESC';
|
|
||||||
app.contributions.page.filters = [];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchContributionDetail(id: number) {
|
async fetchContributionDetail(id: number) {
|
||||||
|
@ -674,6 +638,49 @@ function createDefaultPageData<T>(sort: string): PageData<T> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FNFetchPage = (params: PageQuery) => Promise<any>;
|
||||||
|
interface PageParent<T> {
|
||||||
|
page: PageData<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageFetch<T>(ref: PageParent<T>, fetch: FNFetchPage) {
|
||||||
|
ref.page.fetching = true;
|
||||||
|
try {
|
||||||
|
const params = getPageParams(ref.page);
|
||||||
|
const newPage = await fetch(params);
|
||||||
|
ref.page = {
|
||||||
|
...ref.page,
|
||||||
|
...newPage,
|
||||||
|
fetched: true,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
ref.page.fetching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageParams<T>(page: PageData<T>) {
|
||||||
|
return pick(page, ['page', 'search', 'filters', 'sort']) as PageQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPageParams<T>(ref: PageParent<T>, query: Partial<PageQuery>) {
|
||||||
|
// sometimes we need to reset page to 1
|
||||||
|
if (query.filters || query.search) {
|
||||||
|
query.page = 1;
|
||||||
|
}
|
||||||
|
ref.page = {
|
||||||
|
...ref.page,
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPageParams<T>(ref: PageParent<T>) {
|
||||||
|
ref.page.page = 1;
|
||||||
|
ref.page.search = '';
|
||||||
|
ref.page.sort = 'CREATED:DESC';
|
||||||
|
ref.page.filters = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Attach to window for inspection
|
// Attach to window for inspection
|
||||||
(window as any).appStore = app;
|
(window as any).appStore = app;
|
||||||
|
|
||||||
|
|
|
@ -104,7 +104,6 @@ export interface Proposal {
|
||||||
currentMilestone?: Milestone;
|
currentMilestone?: Milestone;
|
||||||
team: User[];
|
team: User[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
contractStatus: string;
|
|
||||||
target: string;
|
target: string;
|
||||||
contributed: string;
|
contributed: string;
|
||||||
funded: string;
|
funded: string;
|
||||||
|
@ -114,11 +113,19 @@ export interface Proposal {
|
||||||
arbiter: ProposalArbiter;
|
arbiter: ProposalArbiter;
|
||||||
}
|
}
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
commentId: string;
|
id: number;
|
||||||
|
userId: User['userid'];
|
||||||
|
author?: User;
|
||||||
proposalId: Proposal['proposalId'];
|
proposalId: Proposal['proposalId'];
|
||||||
proposal?: Proposal;
|
proposal?: Proposal;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
content: string;
|
content: string;
|
||||||
|
hidden: boolean;
|
||||||
|
reported: boolean;
|
||||||
|
}
|
||||||
|
export interface CommentArgs {
|
||||||
|
hidden: boolean;
|
||||||
|
reported: boolean;
|
||||||
}
|
}
|
||||||
// NOTE: sync with backend/utils/enums.py
|
// NOTE: sync with backend/utils/enums.py
|
||||||
export enum CONTRIBUTION_STATUS {
|
export enum CONTRIBUTION_STATUS {
|
||||||
|
|
|
@ -120,3 +120,24 @@ export const userFilters: Filters = {
|
||||||
list: USER_FILTERS,
|
list: USER_FILTERS,
|
||||||
getById: getFilterById(USER_FILTERS),
|
getById: getFilterById(USER_FILTERS),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Comment
|
||||||
|
const COMMENT_FILTERS = [
|
||||||
|
{
|
||||||
|
id: `REPORTED`,
|
||||||
|
display: `Reported`,
|
||||||
|
color: '#ffaa00',
|
||||||
|
group: 'Misc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `HIDDEN`,
|
||||||
|
display: `Hidden`,
|
||||||
|
color: '#bebebe',
|
||||||
|
group: 'Misc',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const commentFilters = {
|
||||||
|
list: COMMENT_FILTERS,
|
||||||
|
getById: getFilterById(COMMENT_FILTERS),
|
||||||
|
};
|
||||||
|
|
|
@ -3,7 +3,7 @@ from flask import Blueprint, request
|
||||||
from flask_yoloapi import endpoint, parameter
|
from flask_yoloapi import endpoint, parameter
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from grant.comment.models import Comment, user_comments_schema
|
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
|
||||||
from grant.email.send import generate_email, send_email
|
from grant.email.send import generate_email, send_email
|
||||||
from grant.extensions import db
|
from grant.extensions import db
|
||||||
from grant.proposal.models import (
|
from grant.proposal.models import (
|
||||||
|
@ -575,3 +575,47 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return proposal_contribution_schema.dump(contribution), 200
|
return proposal_contribution_schema.dump(contribution), 200
|
||||||
|
|
||||||
|
|
||||||
|
# Comments
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/comments', methods=['GET'])
|
||||||
|
@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)
|
||||||
|
)
|
||||||
|
@admin_auth_required
|
||||||
|
def get_comments(page, filters, search, sort):
|
||||||
|
filters_workaround = request.args.getlist('filters[]')
|
||||||
|
page = pagination.comment(
|
||||||
|
page=page,
|
||||||
|
filters=filters_workaround,
|
||||||
|
search=search,
|
||||||
|
sort=sort,
|
||||||
|
schema=admin_comments_schema
|
||||||
|
)
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/comments/<comment_id>', methods=['PUT'])
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('hidden', type=bool, required=False),
|
||||||
|
parameter('reported', type=bool, required=False),
|
||||||
|
)
|
||||||
|
@admin_auth_required
|
||||||
|
def edit_comment(comment_id, hidden, reported):
|
||||||
|
comment = Comment.query.filter(Comment.id == comment_id).first()
|
||||||
|
if not comment:
|
||||||
|
return {"message": "No comment matching that id"}, 404
|
||||||
|
|
||||||
|
if hidden is not None:
|
||||||
|
comment.hide(hidden)
|
||||||
|
|
||||||
|
if reported is not None:
|
||||||
|
comment.report(reported)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return admin_comment_schema.dump(comment)
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from functools import reduce
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from grant.utils.misc import dt_to_unix
|
from grant.utils.ma_fields import UnixDate
|
||||||
from sqlalchemy.orm import raiseload
|
from sqlalchemy.orm import raiseload
|
||||||
|
|
||||||
|
HIDDEN_CONTENT = '~~comment removed by admin~~'
|
||||||
|
|
||||||
|
|
||||||
class Comment(db.Model):
|
class Comment(db.Model):
|
||||||
__tablename__ = "comment"
|
__tablename__ = "comment"
|
||||||
|
@ -11,6 +14,8 @@ class Comment(db.Model):
|
||||||
id = db.Column(db.Integer(), primary_key=True)
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
date_created = db.Column(db.DateTime)
|
date_created = db.Column(db.DateTime)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
|
hidden = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("FALSE"))
|
||||||
|
reported = db.Column(db.Boolean, nullable=True, default=False, server_default=db.text("FALSE"))
|
||||||
|
|
||||||
parent_comment_id = db.Column(db.Integer, db.ForeignKey("comment.id"), nullable=True)
|
parent_comment_id = db.Column(db.Integer, db.ForeignKey("comment.id"), nullable=True)
|
||||||
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
||||||
|
@ -34,6 +39,24 @@ class Comment(db.Model):
|
||||||
.order_by(Comment.date_created.desc()) \
|
.order_by(Comment.date_created.desc()) \
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
|
def report(self, reported: bool):
|
||||||
|
self.reported = reported
|
||||||
|
db.session.add(self)
|
||||||
|
|
||||||
|
def hide(self, hidden: bool):
|
||||||
|
self.hidden = hidden
|
||||||
|
db.session.add(self)
|
||||||
|
|
||||||
|
|
||||||
|
# are all of the replies hidden?
|
||||||
|
def all_hidden(replies):
|
||||||
|
return reduce(lambda ah, r: ah and r.hidden, replies, True)
|
||||||
|
|
||||||
|
|
||||||
|
# remove replies that are hidden and have all hidden children or no children
|
||||||
|
def filter_dead(replies):
|
||||||
|
return [x for x in replies if not (x.hidden and all_hidden(x.replies))]
|
||||||
|
|
||||||
|
|
||||||
class CommentSchema(ma.Schema):
|
class CommentSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -46,15 +69,23 @@ class CommentSchema(ma.Schema):
|
||||||
"content",
|
"content",
|
||||||
"parent_comment_id",
|
"parent_comment_id",
|
||||||
"date_created",
|
"date_created",
|
||||||
"replies"
|
"replies",
|
||||||
|
"reported",
|
||||||
|
"hidden",
|
||||||
)
|
)
|
||||||
|
|
||||||
date_created = ma.Method("get_date_created")
|
content = ma.Method("get_content")
|
||||||
|
date_created = UnixDate(attribute='date_created')
|
||||||
author = ma.Nested("UserSchema")
|
author = ma.Nested("UserSchema")
|
||||||
replies = ma.Nested("CommentSchema", many=True)
|
# custome handling of replies, was: replies = ma.Nested("CommentSchema", many=True)
|
||||||
|
replies = ma.Method("get_replies")
|
||||||
|
|
||||||
def get_date_created(self, obj):
|
def get_content(self, obj):
|
||||||
return dt_to_unix(obj.date_created)
|
return HIDDEN_CONTENT if obj.hidden else obj.content
|
||||||
|
|
||||||
|
# filter out "dead" comments
|
||||||
|
def get_replies(self, obj):
|
||||||
|
return comments_schema.dump(filter_dead(obj.replies))
|
||||||
|
|
||||||
|
|
||||||
comment_schema = CommentSchema()
|
comment_schema = CommentSchema()
|
||||||
|
@ -69,6 +100,8 @@ class UserCommentSchema(ma.Schema):
|
||||||
"proposal",
|
"proposal",
|
||||||
"content",
|
"content",
|
||||||
"date_created",
|
"date_created",
|
||||||
|
"reported",
|
||||||
|
"hidden",
|
||||||
)
|
)
|
||||||
|
|
||||||
proposal = ma.Nested(
|
proposal = ma.Nested(
|
||||||
|
@ -78,14 +111,56 @@ class UserCommentSchema(ma.Schema):
|
||||||
"milestones",
|
"milestones",
|
||||||
"content",
|
"content",
|
||||||
"invites",
|
"invites",
|
||||||
"updates"
|
"updates",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
date_created = ma.Method("get_date_created")
|
|
||||||
|
|
||||||
def get_date_created(self, obj):
|
content = ma.Method("get_content")
|
||||||
return dt_to_unix(obj.date_created) * 1000
|
date_created = UnixDate(attribute='date_created')
|
||||||
|
|
||||||
|
def get_content(self, obj):
|
||||||
|
return HIDDEN_CONTENT if obj.hidden else obj.content
|
||||||
|
|
||||||
|
|
||||||
user_comment_schema = UserCommentSchema()
|
user_comment_schema = UserCommentSchema()
|
||||||
user_comments_schema = UserCommentSchema(many=True)
|
user_comments_schema = UserCommentSchema(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCommentSchema(ma.Schema):
|
||||||
|
class Meta:
|
||||||
|
model = Comment
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"author",
|
||||||
|
"proposal",
|
||||||
|
"proposal_id",
|
||||||
|
"content",
|
||||||
|
"date_created",
|
||||||
|
"reported",
|
||||||
|
"hidden",
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal = ma.Nested(
|
||||||
|
"ProposalSchema",
|
||||||
|
only=[
|
||||||
|
"proposal_id",
|
||||||
|
"title",
|
||||||
|
"brief"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
author = ma.Nested(
|
||||||
|
"SelfUserSchema",
|
||||||
|
only=[
|
||||||
|
"userid",
|
||||||
|
"email_address",
|
||||||
|
"display_name",
|
||||||
|
"title",
|
||||||
|
"avatar",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
date_created = UnixDate(attribute='date_created')
|
||||||
|
|
||||||
|
|
||||||
|
admin_comment_schema = AdminCommentSchema()
|
||||||
|
admin_comments_schema = AdminCommentSchema(many=True)
|
||||||
|
|
|
@ -70,7 +70,7 @@ def get_proposal_comments(proposal_id, page, filters, search, sort):
|
||||||
filters_workaround = request.args.getlist('filters[]')
|
filters_workaround = request.args.getlist('filters[]')
|
||||||
page = pagination.comment(
|
page = pagination.comment(
|
||||||
schema=comments_schema,
|
schema=comments_schema,
|
||||||
query=Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None),
|
query=Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None, hidden=False),
|
||||||
page=page,
|
page=page,
|
||||||
filters=filters_workaround,
|
filters=filters_workaround,
|
||||||
search=search,
|
search=search,
|
||||||
|
@ -79,6 +79,24 @@ def get_proposal_comments(proposal_id, page, filters, search, sort):
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/comments/<comment_id>/report", methods=["PUT"])
|
||||||
|
@requires_email_verified_auth
|
||||||
|
@endpoint.api()
|
||||||
|
def report_proposal_comment(proposal_id, comment_id):
|
||||||
|
# Make sure proposal exists
|
||||||
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal matching id"}, 404
|
||||||
|
|
||||||
|
comment = Comment.query.filter_by(id=comment_id).first()
|
||||||
|
if not comment:
|
||||||
|
return {"message": "Comment doesn’t exist"}, 404
|
||||||
|
|
||||||
|
comment.report(True)
|
||||||
|
db.session.commit()
|
||||||
|
return None, 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
|
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
|
||||||
@requires_email_verified_auth
|
@requires_email_verified_auth
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
|
|
|
@ -3,6 +3,7 @@ from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from grant.comment.models import Comment, comments_schema
|
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.comment.models import Comment, comments_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
|
||||||
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
|
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
|
||||||
|
@ -232,7 +233,7 @@ class UserPagination(Pagination):
|
||||||
|
|
||||||
class CommentPagination(Pagination):
|
class CommentPagination(Pagination):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.FILTERS = []
|
self.FILTERS = ['REPORTED', 'HIDDEN']
|
||||||
self.PAGE_SIZE = 10
|
self.PAGE_SIZE = 10
|
||||||
self.SORT_MAP = {
|
self.SORT_MAP = {
|
||||||
'CREATED:DESC': Comment.date_created.desc(),
|
'CREATED:DESC': Comment.date_created.desc(),
|
||||||
|
@ -253,18 +254,22 @@ class CommentPagination(Pagination):
|
||||||
|
|
||||||
# FILTER
|
# FILTER
|
||||||
if filters:
|
if filters:
|
||||||
pass
|
self.validate_filters(filters)
|
||||||
|
if 'REPORTED' in filters:
|
||||||
|
query = query.filter(Comment.reported == True)
|
||||||
|
if 'HIDDEN' in filters:
|
||||||
|
query = query.filter(Comment.hidden == True)
|
||||||
|
|
||||||
# SORT (see self.SORT_MAP)
|
# SORT (see self.SORT_MAP)
|
||||||
if sort:
|
if sort:
|
||||||
self.validate_sort(sort)
|
self.validate_sort(sort)
|
||||||
query = query.order_by(self.SORT_MAP[sort])
|
query = query.order_by(self.SORT_MAP[sort])
|
||||||
|
|
||||||
# SEARCH can match txids or amounts
|
# SEARCH
|
||||||
if search:
|
if search:
|
||||||
query = query.filter(or_(
|
query = query.filter(
|
||||||
Comment.content.ilike(f'%{search}%'),
|
Comment.content.ilike(f'%{search}%')
|
||||||
))
|
)
|
||||||
|
|
||||||
res = query.paginate(page, self.PAGE_SIZE, False)
|
res = query.paginate(page, self.PAGE_SIZE, False)
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""basic comment table moderation fields - hidden, reported
|
||||||
|
|
||||||
|
Revision ID: 02acd43b4357
|
||||||
|
Revises: 27975c4a04a4
|
||||||
|
Create Date: 2019-02-17 17:17:17.677275
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '02acd43b4357'
|
||||||
|
down_revision = '27975c4a04a4'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('comment', sa.Column('hidden', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False))
|
||||||
|
op.add_column('comment', sa.Column('reported', sa.Boolean(), server_default=sa.text('FALSE'), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('comment', 'reported')
|
||||||
|
op.drop_column('comment', 'hidden')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -45,6 +45,10 @@ export function getProposalComments(proposalId: number | string, params: PagePar
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
|
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reportProposalComment(proposalId: number, commentId: number) {
|
||||||
|
return axios.put(`/api/v1/proposals/${proposalId}/comments/${commentId}/report`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getProposalUpdates(proposalId: number | string) {
|
export function getProposalUpdates(proposalId: number | string) {
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
|
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { Button } from 'antd';
|
import { Button, message } from 'antd';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import UserAvatar from 'components/UserAvatar';
|
import UserAvatar from 'components/UserAvatar';
|
||||||
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
|
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
|
||||||
import { postProposalComment } from 'modules/proposals/actions';
|
import { postProposalComment, reportProposalComment } from 'modules/proposals/actions';
|
||||||
import { getIsSignedIn } from 'modules/auth/selectors';
|
import { getIsSignedIn } from 'modules/auth/selectors';
|
||||||
import { Comment as IComment } from 'types';
|
import { Comment as IComment } from 'types';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
@ -24,6 +24,7 @@ interface StateProps {
|
||||||
|
|
||||||
interface DispatchProps {
|
interface DispatchProps {
|
||||||
postProposalComment: typeof postProposalComment;
|
postProposalComment: typeof postProposalComment;
|
||||||
|
reportProposalComment: typeof reportProposalComment;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = OwnProps & StateProps & DispatchProps;
|
type Props = OwnProps & StateProps & DispatchProps;
|
||||||
|
@ -76,7 +77,12 @@ class Comment extends React.Component<Props> {
|
||||||
<a className="Comment-controls-button" onClick={this.toggleReply}>
|
<a className="Comment-controls-button" onClick={this.toggleReply}>
|
||||||
{isReplying ? 'Cancel' : 'Reply'}
|
{isReplying ? 'Cancel' : 'Reply'}
|
||||||
</a>
|
</a>
|
||||||
{/*<a className="Comment-controls-button">Report</a>*/}
|
{!comment.hidden &&
|
||||||
|
!comment.reported && (
|
||||||
|
<a className="Comment-controls-button" onClick={this.report}>
|
||||||
|
Report
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -120,6 +126,16 @@ class Comment extends React.Component<Props> {
|
||||||
const { reply } = this.state;
|
const { reply } = this.state;
|
||||||
this.props.postProposalComment(comment.proposalId, reply, comment.id);
|
this.props.postProposalComment(comment.proposalId, reply, comment.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private report = async () => {
|
||||||
|
const { proposalId, id } = this.props.comment;
|
||||||
|
const res = await this.props.reportProposalComment(proposalId, id);
|
||||||
|
if ((res as any).error) {
|
||||||
|
message.error('Problem reporting comment: ' + (res as any).payload);
|
||||||
|
} else {
|
||||||
|
message.success('Comment reported');
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
|
const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
@ -130,6 +146,7 @@ const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
postProposalComment,
|
postProposalComment,
|
||||||
|
reportProposalComment,
|
||||||
},
|
},
|
||||||
)(Comment);
|
)(Comment);
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
getProposal,
|
getProposal,
|
||||||
getProposalComments,
|
getProposalComments,
|
||||||
getProposalUpdates,
|
getProposalUpdates,
|
||||||
|
reportProposalComment as apiReportProposalComment,
|
||||||
getProposalContributions,
|
getProposalContributions,
|
||||||
postProposalComment as apiPostProposalComment,
|
postProposalComment as apiPostProposalComment,
|
||||||
requestProposalPayout,
|
requestProposalPayout,
|
||||||
|
@ -204,3 +205,28 @@ export function postProposalComment(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reportProposalComment(
|
||||||
|
proposalId: Proposal['proposalId'],
|
||||||
|
commentId: Comment['id'],
|
||||||
|
) {
|
||||||
|
return async (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch({ type: types.REPORT_PROPOSAL_COMMENT_PENDING, payload: { commentId } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiReportProposalComment(proposalId, commentId);
|
||||||
|
return dispatch({
|
||||||
|
type: types.REPORT_PROPOSAL_COMMENT_FULFILLED,
|
||||||
|
payload: {
|
||||||
|
commentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return dispatch({
|
||||||
|
type: types.REPORT_PROPOSAL_COMMENT_REJECTED,
|
||||||
|
payload: err.message || err.toString(),
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -172,6 +172,42 @@ function addPostedComment(state: ProposalState, payload: PostCommentPayload) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCommentInStore(
|
||||||
|
state: ProposalState,
|
||||||
|
commentId: Comment['id'],
|
||||||
|
update: Partial<Comment>,
|
||||||
|
) {
|
||||||
|
// clone so we can mutate with great abandon!
|
||||||
|
const pages = cloneDeep(state.detailComments.pages);
|
||||||
|
// recursive populate replies for nested comment
|
||||||
|
const f = (id: number, p: Comment) => {
|
||||||
|
if (p.id === id) {
|
||||||
|
Object.entries(update).forEach(([k, v]) => {
|
||||||
|
(p as any)[k] = v;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
p.replies.forEach(x => f(id, x));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// pages > page > comments
|
||||||
|
pages.forEach(p =>
|
||||||
|
p.forEach(c => {
|
||||||
|
f(commentId, c);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isPostCommentPending: false,
|
||||||
|
detailComments: {
|
||||||
|
...state.detailComments,
|
||||||
|
pages,
|
||||||
|
total: state.detailComments.total + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default (state = INITIAL_STATE, action: any) => {
|
export default (state = INITIAL_STATE, action: any) => {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
@ -336,6 +372,9 @@ export default (state = INITIAL_STATE, action: any) => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case types.REPORT_PROPOSAL_COMMENT_FULFILLED:
|
||||||
|
return updateCommentInStore(state, payload.commentId, { reported: true });
|
||||||
|
|
||||||
case types.PROPOSAL_UPDATES_PENDING:
|
case types.PROPOSAL_UPDATES_PENDING:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -47,6 +47,11 @@ enum proposalTypes {
|
||||||
PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED',
|
PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED',
|
||||||
PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED',
|
PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED',
|
||||||
PROPOSAL_PAYOUT_ACCEPT_PENDING = 'PROPOSAL_PAYOUT_ACCEPT_PENDING',
|
PROPOSAL_PAYOUT_ACCEPT_PENDING = 'PROPOSAL_PAYOUT_ACCEPT_PENDING',
|
||||||
|
|
||||||
|
REPORT_PROPOSAL_COMMENT = 'REPORT_PROPOSAL_COMMENT',
|
||||||
|
REPORT_PROPOSAL_COMMENT_PENDING = 'REPORT_PROPOSAL_COMMENT_PENDING',
|
||||||
|
REPORT_PROPOSAL_COMMENT_FULFILLED = 'REPORT_PROPOSAL_COMMENT_FULFILLED',
|
||||||
|
REPORT_PROPOSAL_COMMENT_REJECTED = 'REPORT_PROPOSAL_COMMENT_REJECTED',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default proposalTypes;
|
export default proposalTypes;
|
||||||
|
|
|
@ -7,6 +7,8 @@ export interface Comment {
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
author: User;
|
author: User;
|
||||||
replies: Comment[];
|
replies: Comment[];
|
||||||
|
reported: boolean;
|
||||||
|
hidden: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserComment {
|
export interface UserComment {
|
||||||
|
|
Loading…
Reference in New Issue