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 ContributionForm from 'components/ContributionForm';
|
||||
import ContributionDetail from 'components/ContributionDetail';
|
||||
import Moderation from 'components/Moderation';
|
||||
|
||||
import 'styles/style.less';
|
||||
|
||||
|
@ -49,6 +50,7 @@ class Routes extends React.Component<Props> {
|
|||
<Route path="/contributions/:id" component={ContributionDetail} />
|
||||
<Route path="/contributions" component={Contributions} />
|
||||
<Route path="/emails/:type?" component={Emails} />
|
||||
<Route path="/moderation" component={Moderation} />
|
||||
</Switch>
|
||||
)}
|
||||
</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> {
|
||||
page: PageData<T>;
|
||||
filters: Filters;
|
||||
filters: null | Filters;
|
||||
sorts: string[];
|
||||
searchPlaceholder?: string;
|
||||
controlsExtra?: React.ReactNode;
|
||||
|
@ -39,7 +39,7 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
|
|||
} = this.props;
|
||||
const loading = !page.fetched || page.fetching;
|
||||
|
||||
const statusFilterMenu = (
|
||||
const statusFilterMenu = filters && (
|
||||
<Menu onClick={this.handleFilterClick}>
|
||||
{filters.list.map(f => (
|
||||
<Menu.Item key={f.id}>{f.display}</Menu.Item>
|
||||
|
@ -63,11 +63,13 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
|
|||
placeholder={searchPlaceholder}
|
||||
onSearch={this.handleSearch}
|
||||
/>
|
||||
{filters && (
|
||||
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
|
||||
<Button>
|
||||
Filter <Icon type="down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
<Dropdown overlay={sortMenu} trigger={['click']}>
|
||||
<Button>
|
||||
{'Sort ' + page.sort} <Icon type="down" />
|
||||
|
@ -86,7 +88,8 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!!page.filters.length && (
|
||||
{filters &&
|
||||
!!page.filters.length && (
|
||||
<div className="Pageable-filters">
|
||||
Filters:{' '}
|
||||
{page.filters.map(fId => {
|
||||
|
|
|
@ -3,7 +3,19 @@ import moment from 'moment';
|
|||
import { view } from 'react-easy-state';
|
||||
import { RouteComponentProps, withRouter } from 'react-router';
|
||||
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 { FormComponentProps } from 'antd/lib/form';
|
||||
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
|
||||
|
@ -100,7 +112,9 @@ class RFPForm extends React.Component<Props, State> {
|
|||
{rfpId && (
|
||||
<Form.Item
|
||||
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', {
|
||||
initialValue: defaults.status,
|
||||
|
@ -214,10 +228,10 @@ class RFPForm extends React.Component<Props, State> {
|
|||
help="Date that proposals will stop being submittable by"
|
||||
>
|
||||
{getFieldDecorator('dateCloses', {
|
||||
initialValue: defaults.dateCloses ? moment(defaults.dateCloses * 1000) : undefined,
|
||||
})(
|
||||
<DatePicker size="large" />
|
||||
)}
|
||||
initialValue: defaults.dateCloses
|
||||
? moment(defaults.dateCloses * 1000)
|
||||
: undefined,
|
||||
})(<DatePicker size="large" />)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</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>
|
||||
</Link>
|
||||
</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}>
|
||||
<Icon type="logout" />
|
||||
<span className="nav-text">Logout</span>
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
EmailExample,
|
||||
PageQuery,
|
||||
PageData,
|
||||
CommentArgs,
|
||||
} from './types';
|
||||
|
||||
// API
|
||||
|
@ -100,6 +101,16 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st
|
|||
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) {
|
||||
const { data } = await api.put(
|
||||
`/admin/proposals/${proposalId}/milestone/${milestoneId}/paid`,
|
||||
|
@ -200,6 +211,12 @@ const app = store({
|
|||
proposalDetailApproving: false,
|
||||
proposalDetailMarkingMilestonePaid: false,
|
||||
|
||||
comments: {
|
||||
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
||||
},
|
||||
commentSaving: false,
|
||||
commentSaved: false,
|
||||
|
||||
rfps: [] as RFP[],
|
||||
rfpsFetching: false,
|
||||
rfpsFetched: false,
|
||||
|
@ -285,40 +302,15 @@ const app = store({
|
|||
// Users
|
||||
|
||||
async fetchUsers() {
|
||||
app.users.page.fetching = true;
|
||||
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;
|
||||
return await pageFetch(app.users, fetchUsers);
|
||||
},
|
||||
|
||||
getUserPageQuery() {
|
||||
return pick(app.users.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
|
||||
},
|
||||
|
||||
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,
|
||||
};
|
||||
setUserPageQuery(params: Partial<PageQuery>) {
|
||||
setPageParams(app.users, params);
|
||||
},
|
||||
|
||||
resetUserPageQuery() {
|
||||
app.users.page.page = 1;
|
||||
app.users.page.search = '';
|
||||
app.users.page.sort = 'CREATED:DESC';
|
||||
app.users.page.filters = [];
|
||||
resetPageParams(app.users);
|
||||
},
|
||||
|
||||
async fetchUserDetail(id: number) {
|
||||
|
@ -404,40 +396,15 @@ const app = store({
|
|||
// Proposals
|
||||
|
||||
async fetchProposals() {
|
||||
app.proposals.page.fetching = true;
|
||||
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;
|
||||
return await pageFetch(app.proposals, fetchProposals);
|
||||
},
|
||||
|
||||
setProposalPageQuery(query: Partial<PageQuery>) {
|
||||
// sometimes we need to reset page to 1
|
||||
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;
|
||||
setProposalPageQuery(params: Partial<PageQuery>) {
|
||||
setPageParams(app.proposals, params);
|
||||
},
|
||||
|
||||
resetProposalPageQuery() {
|
||||
app.proposals.page.page = 1;
|
||||
app.proposals.page.search = '';
|
||||
app.proposals.page.sort = 'CREATED:DESC';
|
||||
app.proposals.page.filters = [];
|
||||
resetPageParams(app.proposals);
|
||||
},
|
||||
|
||||
async fetchProposalDetail(id: number) {
|
||||
|
@ -505,6 +472,33 @@ const app = store({
|
|||
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
|
||||
|
||||
async getEmailExample(type: string) {
|
||||
|
@ -573,45 +567,15 @@ const app = store({
|
|||
// Contributions
|
||||
|
||||
async fetchContributions() {
|
||||
app.contributions.page.fetching = true;
|
||||
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;
|
||||
return await pageFetch(app.contributions, getContributions);
|
||||
},
|
||||
|
||||
setContributionPageQuery(query: Partial<PageQuery>) {
|
||||
// sometimes we need to reset page to 1
|
||||
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;
|
||||
setContributionPageQuery(params: Partial<PageQuery>) {
|
||||
setPageParams(app.contributions, params);
|
||||
},
|
||||
|
||||
resetContributionPageQuery() {
|
||||
app.contributions.page.page = 1;
|
||||
app.contributions.page.search = '';
|
||||
app.contributions.page.sort = 'CREATED:DESC';
|
||||
app.contributions.page.filters = [];
|
||||
resetPageParams(app.contributions);
|
||||
},
|
||||
|
||||
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
|
||||
(window as any).appStore = app;
|
||||
|
||||
|
|
|
@ -104,7 +104,6 @@ export interface Proposal {
|
|||
currentMilestone?: Milestone;
|
||||
team: User[];
|
||||
comments: Comment[];
|
||||
contractStatus: string;
|
||||
target: string;
|
||||
contributed: string;
|
||||
funded: string;
|
||||
|
@ -114,11 +113,19 @@ export interface Proposal {
|
|||
arbiter: ProposalArbiter;
|
||||
}
|
||||
export interface Comment {
|
||||
commentId: string;
|
||||
id: number;
|
||||
userId: User['userid'];
|
||||
author?: User;
|
||||
proposalId: Proposal['proposalId'];
|
||||
proposal?: Proposal;
|
||||
dateCreated: number;
|
||||
content: string;
|
||||
hidden: boolean;
|
||||
reported: boolean;
|
||||
}
|
||||
export interface CommentArgs {
|
||||
hidden: boolean;
|
||||
reported: boolean;
|
||||
}
|
||||
// NOTE: sync with backend/utils/enums.py
|
||||
export enum CONTRIBUTION_STATUS {
|
||||
|
|
|
@ -120,3 +120,24 @@ export const userFilters: Filters = {
|
|||
list: 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 decimal import Decimal
|
||||
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.extensions import db
|
||||
from grant.proposal.models import (
|
||||
|
@ -575,3 +575,47 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
|||
|
||||
db.session.commit()
|
||||
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
|
||||
|
||||
from functools import reduce
|
||||
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
|
||||
|
||||
HIDDEN_CONTENT = '~~comment removed by admin~~'
|
||||
|
||||
|
||||
class Comment(db.Model):
|
||||
__tablename__ = "comment"
|
||||
|
@ -11,6 +14,8 @@ class Comment(db.Model):
|
|||
id = db.Column(db.Integer(), primary_key=True)
|
||||
date_created = db.Column(db.DateTime)
|
||||
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)
|
||||
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()) \
|
||||
.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 Meta:
|
||||
|
@ -46,15 +69,23 @@ class CommentSchema(ma.Schema):
|
|||
"content",
|
||||
"parent_comment_id",
|
||||
"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")
|
||||
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):
|
||||
return dt_to_unix(obj.date_created)
|
||||
def get_content(self, obj):
|
||||
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()
|
||||
|
@ -69,6 +100,8 @@ class UserCommentSchema(ma.Schema):
|
|||
"proposal",
|
||||
"content",
|
||||
"date_created",
|
||||
"reported",
|
||||
"hidden",
|
||||
)
|
||||
|
||||
proposal = ma.Nested(
|
||||
|
@ -78,14 +111,56 @@ class UserCommentSchema(ma.Schema):
|
|||
"milestones",
|
||||
"content",
|
||||
"invites",
|
||||
"updates"
|
||||
"updates",
|
||||
]
|
||||
)
|
||||
date_created = ma.Method("get_date_created")
|
||||
|
||||
def get_date_created(self, obj):
|
||||
return dt_to_unix(obj.date_created) * 1000
|
||||
content = ma.Method("get_content")
|
||||
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_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[]')
|
||||
page = pagination.comment(
|
||||
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,
|
||||
filters=filters_workaround,
|
||||
search=search,
|
||||
|
@ -79,6 +79,24 @@ def get_proposal_comments(proposal_id, page, filters, search, sort):
|
|||
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"])
|
||||
@requires_email_verified_auth
|
||||
@endpoint.api(
|
||||
|
|
|
@ -3,6 +3,7 @@ from sqlalchemy import or_, and_
|
|||
|
||||
from grant.comment.models import Comment, comments_schema
|
||||
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
||||
from grant.comment.models import Comment, comments_schema
|
||||
from grant.user.models import User, users_schema
|
||||
from grant.milestone.models import Milestone
|
||||
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
|
||||
|
@ -232,7 +233,7 @@ class UserPagination(Pagination):
|
|||
|
||||
class CommentPagination(Pagination):
|
||||
def __init__(self):
|
||||
self.FILTERS = []
|
||||
self.FILTERS = ['REPORTED', 'HIDDEN']
|
||||
self.PAGE_SIZE = 10
|
||||
self.SORT_MAP = {
|
||||
'CREATED:DESC': Comment.date_created.desc(),
|
||||
|
@ -253,18 +254,22 @@ class CommentPagination(Pagination):
|
|||
|
||||
# FILTER
|
||||
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)
|
||||
if sort:
|
||||
self.validate_sort(sort)
|
||||
query = query.order_by(self.SORT_MAP[sort])
|
||||
|
||||
# SEARCH can match txids or amounts
|
||||
# SEARCH
|
||||
if search:
|
||||
query = query.filter(or_(
|
||||
Comment.content.ilike(f'%{search}%'),
|
||||
))
|
||||
query = query.filter(
|
||||
Comment.content.ilike(f'%{search}%')
|
||||
)
|
||||
|
||||
res = query.paginate(page, self.PAGE_SIZE, False)
|
||||
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 });
|
||||
}
|
||||
|
||||
export function reportProposalComment(proposalId: number, commentId: number) {
|
||||
return axios.put(`/api/v1/proposals/${proposalId}/comments/${commentId}/report`);
|
||||
}
|
||||
|
||||
export function getProposalUpdates(proposalId: number | string) {
|
||||
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import moment from 'moment';
|
||||
import { Button } from 'antd';
|
||||
import { Button, message } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Markdown from 'components/Markdown';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
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 { Comment as IComment } from 'types';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
@ -24,6 +24,7 @@ interface StateProps {
|
|||
|
||||
interface DispatchProps {
|
||||
postProposalComment: typeof postProposalComment;
|
||||
reportProposalComment: typeof reportProposalComment;
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & DispatchProps;
|
||||
|
@ -76,7 +77,12 @@ class Comment extends React.Component<Props> {
|
|||
<a className="Comment-controls-button" onClick={this.toggleReply}>
|
||||
{isReplying ? 'Cancel' : 'Reply'}
|
||||
</a>
|
||||
{/*<a className="Comment-controls-button">Report</a>*/}
|
||||
{!comment.hidden &&
|
||||
!comment.reported && (
|
||||
<a className="Comment-controls-button" onClick={this.report}>
|
||||
Report
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -120,6 +126,16 @@ class Comment extends React.Component<Props> {
|
|||
const { reply } = this.state;
|
||||
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>(
|
||||
|
@ -130,6 +146,7 @@ const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
|
|||
}),
|
||||
{
|
||||
postProposalComment,
|
||||
reportProposalComment,
|
||||
},
|
||||
)(Comment);
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
getProposal,
|
||||
getProposalComments,
|
||||
getProposalUpdates,
|
||||
reportProposalComment as apiReportProposalComment,
|
||||
getProposalContributions,
|
||||
postProposalComment as apiPostProposalComment,
|
||||
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) => {
|
||||
const { payload } = action;
|
||||
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:
|
||||
return {
|
||||
...state,
|
||||
|
|
|
@ -47,6 +47,11 @@ enum proposalTypes {
|
|||
PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED',
|
||||
PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED',
|
||||
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;
|
||||
|
|
|
@ -7,6 +7,8 @@ export interface Comment {
|
|||
dateCreated: number;
|
||||
author: User;
|
||||
replies: Comment[];
|
||||
reported: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface UserComment {
|
||||
|
|
Loading…
Reference in New Issue