Add anonymous feedback (#380)

This commit is contained in:
Riccardo Graziosi
2024-07-12 20:38:46 +02:00
committed by GitHub
parent 7a37dae22d
commit a49b5695f5
71 changed files with 1446 additions and 255 deletions

View File

@@ -22,11 +22,15 @@
@import 'components/Roadmap'; @import 'components/Roadmap';
@import 'components/Billing'; @import 'components/Billing';
/* Site Settings Components */ /* Site Settings Components */
@import 'components/SiteSettings'; @import 'components/SiteSettings';
@import 'components/SiteSettings/Boards'; @import 'components/SiteSettings/General';
@import 'components/SiteSettings/PostStatuses'; @import 'components/SiteSettings/Boards';
@import 'components/SiteSettings/Roadmap'; @import 'components/SiteSettings/PostStatuses';
@import 'components/SiteSettings/Users'; @import 'components/SiteSettings/Roadmap';
@import 'components/SiteSettings/Authentication'; @import 'components/SiteSettings/Authentication';
@import 'components/SiteSettings/Appearance/'; @import 'components/SiteSettings/Appearance/';
/* Moderation Components */
@import 'components/Moderation/Feedback';
@import 'components/Moderation/Users';

View File

@@ -159,16 +159,20 @@ body {
@extend @extend
.badge, .badge,
.badge-pill, .badge-pill,
.p-2; .p-2,
.ml-1,
.mr-1;
font-size: 13px; font-size: 13px;
text-transform: uppercase;
} }
.badgeLight { .badgeLight {
@extend .badge-light; @extend .badge-light;
background-color: var(--astuto-grey-light); background-color: var(--astuto-grey-light);
} }
.badgeWarning { @extend .badge-warning; }
.badgeDanger { @extend .badge-danger; }
.container { .container {
max-width: 960px; max-width: 960px;
@@ -324,3 +328,25 @@ body {
.text-center, .text-center,
.m-0; .m-0;
} }
.highlighted {
@extend .p-2;
background-color: rgba(255, 255, 0, 0.2);
}
.notificationDot {
display: inline-block;
color: white;
background-color: var(--primary-color);
min-width: 22px;
font-size: 14px;
border-radius: 100%;
text-align: center;
}
.notificationDot.notificationDotTop {
position: relative;
bottom: 8px;
scale: 80%;
}

View File

@@ -24,13 +24,37 @@
} }
.newPostForm { .newPostForm {
@extend .my-2; @extend
.mt-4,
.mb-2;
#postDescription { #postDescription {
height: 100px; height: 100px;
min-height: 100px; min-height: 100px;
resize: vertical; resize: vertical;
} }
.form-group-dnf {
margin: 0;
padding: 0;
height: 1px;
/* honeypot field 1 */
#email {
visibility: hidden;
height: 1px;
}
}
/* honeypot field 2 */
#name {
position: absolute;
left: -9999px;
}
}
.anonymousFeedbackLink {
@extend .my-2;
} }
} }

View File

@@ -0,0 +1,88 @@
.feedbackModerationContainer {
.badges {
@extend
.d-flex,
.flex-row,
.flex-wrap;
row-gap: 8px;
}
.changeFeedbackModerationSettingsLink {
@extend
.align-self-auto,
.mt-2;
}
.filterModerationFeedbackNav {
@extend
.nav,
.nav-pills,
.align-self-center,
.px-2,
.mt-4;
.nav-item {
cursor: pointer;
}
.nav-link {
color: var(--astuto-black);
&.active {
color: white;
background-color: var(--astuto-black);
}
}
}
.emptyList {
@extend .mt-4, .mb-2;
text-align: center;
}
.feedbackModerationList {
.feedbackListItem {
@extend
.d-flex,
.flex-row,
.justify-content-between,
.my-4,
.mx-2,
.p-2;
.feedbackListItemIconAndContent {
@extend .d-flex;
.feedbackListItemIcon {
@extend .align-self-center;
}
.feedbackListItemContent {
@extend .ml-4;
.feedbackListItemTitle {
@extend
.font-weight-bold,
.mb-1;
&:hover {
cursor: pointer;
text-decoration: underline;
}
}
.feedbackListItemDescription {
color: var(--astuto-grey);
}
}
}
.feedbackListItemActions {
@extend
.d-flex;
}
}
}
}

View File

@@ -110,7 +110,9 @@
.mb-3; .mb-3;
.postInfo { .postInfo {
@extend .d-flex; @extend
.d-flex,
.mb-2;
span { span {
@extend .mr-2; @extend .mr-2;

View File

@@ -0,0 +1,7 @@
.generalSiteSettingsContainer {
.settingsGroup {
@extend .mt-4;
scroll-margin-top: 96px;
}
}

View File

@@ -0,0 +1,18 @@
class ModerationController < ApplicationController
include ApplicationHelper
before_action :authenticate_moderator
before_action :set_page_title
def feedback
end
def users
end
private
def set_page_title
@page_title = t('header.menu.moderation')
end
end

View File

@@ -1,5 +1,7 @@
class PostsController < ApplicationController class PostsController < ApplicationController
before_action :authenticate_user!, only: [:create, :update, :destroy] before_action :authenticate_user!, only: [:update, :destroy]
before_action :authenticate_moderator, only: [:moderation]
before_action :authenticate_moderator_if_post_not_approved, only: [:show]
before_action :check_tenant_subscription, only: [:create, :update, :destroy] before_action :check_tenant_subscription, only: [:create, :update, :destroy]
def index def index
@@ -23,6 +25,7 @@ class PostsController < ApplicationController
.group('posts.id') .group('posts.id')
.where(board_id: params[:board_id] || Board.first.id) .where(board_id: params[:board_id] || Board.first.id)
.where(created_at: start_date.beginning_of_day..end_date.end_of_day) .where(created_at: start_date.beginning_of_day..end_date.end_of_day)
.where(approval_status: "approved")
.search_by_name_or_description(params[:search]) .search_by_name_or_description(params[:search])
.order_by(params[:sort_by]) .order_by(params[:sort_by])
.page(params[:page]) .page(params[:page])
@@ -34,11 +37,28 @@ class PostsController < ApplicationController
end end
def create def create
@post = Post.new if anti_spam_checks || invalid_anonymous_submission
@post.assign_attributes(post_create_params) render json: {
error: t('errors.unknown')
}, status: :unprocessable_entity
return
end
# handle anonymous feedback
approval_status = "pending"
is_anonymous = params[:post][:is_anonymous]
if Current.tenant.tenant_setting.feedback_approval_policy == "never_require_approval" ||
(Current.tenant.tenant_setting.feedback_approval_policy == "anonymous_require_approval" && !is_anonymous) ||
(user_signed_in? && current_user.moderator?)
approval_status = "approved"
end
@post = Post.new(approval_status: approval_status)
@post.assign_attributes(post_create_params(is_anonymous: is_anonymous))
if @post.save if @post.save
Follow.create(post_id: @post.id, user_id: current_user.id) Follow.create(post_id: @post.id, user_id: current_user.id) unless is_anonymous
render json: @post, status: :created render json: @post, status: :created
else else
@@ -56,6 +76,7 @@ class PostsController < ApplicationController
:title, :title,
:slug, :slug,
:description, :description,
:approval_status,
:board_id, :board_id,
:user_id, :user_id,
:post_status_id, :post_status_id,
@@ -64,7 +85,7 @@ class PostsController < ApplicationController
'users.email as user_email', 'users.email as user_email',
'users.full_name as user_full_name' 'users.full_name as user_full_name'
) )
.joins(:user) .eager_load(:user) # left outer join
.find(params[:id]) .find(params[:id])
@post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc) @post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc)
@@ -121,13 +142,49 @@ class PostsController < ApplicationController
end end
end end
# Returns a list of posts for moderation in JSON
def moderation
posts = Post
.select(
:id,
:title,
:description,
:slug,
:approval_status,
:user_id,
:board_id,
:created_at,
'users.email as user_email',
'users.full_name as user_full_name'
)
.eager_load(:user)
.where(approval_status: ["pending", "rejected"])
.order_by(created_at: :desc)
.limit(100)
render json: posts
end
private private
def post_create_params def authenticate_moderator_if_post_not_approved
post = Post.friendly.find(params[:id])
authenticate_moderator unless post.approval_status == "approved"
end
def anti_spam_checks
params[:post][:dnf1] != "" || params[:post][:dnf2] != "" || Time.now.to_i - params[:post][:form_rendered_at] < 3
end
def invalid_anonymous_submission
(params[:post][:is_anonymous] == false && !user_signed_in?) || (params[:post][:is_anonymous] == true && Current.tenant.tenant_setting.allow_anonymous_feedback == false)
end
def post_create_params(is_anonymous: false)
params params
.require(:post) .require(:post)
.permit(policy(@post).permitted_attributes_for_create) .permit(policy(@post).permitted_attributes_for_create)
.merge(user_id: current_user.id) .merge(user_id: is_anonymous ? nil : current_user.id)
end end
def post_update_params def post_update_params

View File

@@ -1,12 +1,7 @@
class SiteSettingsController < ApplicationController class SiteSettingsController < ApplicationController
include ApplicationHelper include ApplicationHelper
before_action :authenticate_admin, before_action :authenticate_admin
only: [:general, :authentication, :boards, :post_statuses, :roadmap, :appearance]
before_action :authenticate_moderator,
only: [:users]
before_action :set_page_title before_action :set_page_title
def general def general
@@ -27,9 +22,6 @@ class SiteSettingsController < ApplicationController
def appearance def appearance
end end
def users
end
private private
def set_page_title def set_page_title

View File

@@ -79,3 +79,19 @@ export const requestPosts = (
dispatch(postsRequestFailure(e)); dispatch(postsRequestFailure(e));
} }
} }
// Used to get posts that require moderation (i.e. pending or rejected posts)
export const requestPostsForModeration = (
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postsRequestStart());
try {
const response = await fetch('/posts/moderation');
const json = await response.json();
dispatch(postsRequestSuccess(json, 1));
} catch (e) {
dispatch(postsRequestFailure(e));
}
}

View File

@@ -4,6 +4,7 @@ import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders"; import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import IPostJSON from "../../interfaces/json/IPost"; import IPostJSON from "../../interfaces/json/IPost";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import { PostApprovalStatus } from "../../interfaces/IPost";
export const POST_UPDATE_START = 'POST_UPDATE_START'; export const POST_UPDATE_START = 'POST_UPDATE_START';
interface PostUpdateStartAction { interface PostUpdateStartAction {
@@ -31,7 +32,7 @@ const postUpdateStart = (): PostUpdateStartAction => ({
type: POST_UPDATE_START, type: POST_UPDATE_START,
}); });
const postUpdateSuccess = ( export const postUpdateSuccess = (
postJSON: IPostJSON, postJSON: IPostJSON,
): PostUpdateSuccessAction => ({ ): PostUpdateSuccessAction => ({
type: POST_UPDATE_SUCCESS, type: POST_UPDATE_SUCCESS,
@@ -81,3 +82,36 @@ export const updatePost = (
return Promise.resolve(null); return Promise.resolve(null);
} }
}; };
export const updatePostApprovalStatus = (
id: number,
approvalStatus: PostApprovalStatus,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postUpdateStart());
try {
const res = await fetch(`/posts/${id}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
post: {
approval_status: approvalStatus,
}
}),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
dispatch(postUpdateSuccess(json));
} else {
dispatch(postUpdateFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(postUpdateFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -20,7 +20,9 @@ interface Props {
board: IBoard; board: IBoard;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
currentUserFullName: string;
tenantSetting: ITenantSetting; tenantSetting: ITenantSetting;
componentRenderedAt: number;
authenticityToken: string; authenticityToken: string;
posts: PostsState; posts: PostsState;
postStatuses: PostStatusesState; postStatuses: PostStatusesState;
@@ -91,7 +93,9 @@ class BoardP extends React.Component<Props> {
board, board,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
currentUserFullName,
tenantSetting, tenantSetting,
componentRenderedAt,
authenticityToken, authenticityToken,
posts, posts,
postStatuses, postStatuses,
@@ -110,8 +114,12 @@ class BoardP extends React.Component<Props> {
<NewPost <NewPost
board={board} board={board}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
currentUserFullName={currentUserFullName}
isAnonymousFeedbackAllowed={tenantSetting.allow_anonymous_feedback}
componentRenderedAt={componentRenderedAt}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
<div className="sidebarFilters"> <div className="sidebarFilters">
<SearchFilter <SearchFilter
searchQuery={filters.searchQuery} searchQuery={filters.searchQuery}

View File

@@ -13,11 +13,17 @@ import Button from '../common/Button';
import IBoard from '../../interfaces/IBoard'; import IBoard from '../../interfaces/IBoard';
import buildRequestHeaders from '../../helpers/buildRequestHeaders'; import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status'; import HttpStatus from '../../constants/http_status';
import { POST_APPROVAL_STATUS_APPROVED } from '../../interfaces/IPost';
interface Props { interface Props {
board: IBoard; board: IBoard;
isLoggedIn: boolean; isLoggedIn: boolean;
currentUserFullName: string;
isAnonymousFeedbackAllowed: boolean;
authenticityToken: string; authenticityToken: string;
// Time check anti-spam measure
componentRenderedAt: number;
} }
interface State { interface State {
@@ -28,6 +34,13 @@ interface State {
title: string; title: string;
description: string; description: string;
isSubmissionAnonymous: boolean;
// Honeypot anti-spam measure
// These fields are honeypots: they are not visibile and must not be filled
// dnf = do not fill
dnf1: string;
dnf2: string;
} }
class NewPost extends React.Component<Props, State> { class NewPost extends React.Component<Props, State> {
@@ -42,12 +55,19 @@ class NewPost extends React.Component<Props, State> {
title: '', title: '',
description: '', description: '',
isSubmissionAnonymous: false,
dnf1: '',
dnf2: '',
}; };
this.toggleForm = this.toggleForm.bind(this); this.toggleForm = this.toggleForm.bind(this);
this.onTitleChange = this.onTitleChange.bind(this); this.onTitleChange = this.onTitleChange.bind(this);
this.onDescriptionChange = this.onDescriptionChange.bind(this); this.onDescriptionChange = this.onDescriptionChange.bind(this);
this.submitForm = this.submitForm.bind(this); this.submitForm = this.submitForm.bind(this);
this.onDnf1Change = this.onDnf1Change.bind(this)
this.onDnf2Change = this.onDnf2Change.bind(this)
} }
toggleForm() { toggleForm() {
@@ -72,6 +92,18 @@ class NewPost extends React.Component<Props, State> {
}); });
} }
onDnf1Change(dnf1: string) {
this.setState({
dnf1,
});
}
onDnf2Change(dnf2: string) {
this.setState({
dnf2,
});
}
async submitForm(e: React.FormEvent) { async submitForm(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -82,8 +114,8 @@ class NewPost extends React.Component<Props, State> {
}); });
const boardId = this.props.board.id; const boardId = this.props.board.id;
const { authenticityToken } = this.props; const { authenticityToken, componentRenderedAt } = this.props;
const { title, description } = this.state; const { title, description, isSubmissionAnonymous, dnf1, dnf2 } = this.state;
if (title === '') { if (title === '') {
this.setState({ this.setState({
@@ -102,6 +134,12 @@ class NewPost extends React.Component<Props, State> {
title, title,
description, description,
board_id: boardId, board_id: boardId,
is_anonymous: isSubmissionAnonymous,
dnf1,
dnf2,
form_rendered_at: componentRenderedAt,
}, },
}), }),
}); });
@@ -109,16 +147,22 @@ class NewPost extends React.Component<Props, State> {
this.setState({isLoading: false}); this.setState({isLoading: false});
if (res.status === HttpStatus.Created) { if (res.status === HttpStatus.Created) {
if (json.approval_status === POST_APPROVAL_STATUS_APPROVED) {
this.setState({ this.setState({
success: I18n.t('board.new_post.submit_success'), success: I18n.t('board.new_post.submit_success'),
title: '',
description: '',
}); });
setTimeout(() => ( setTimeout(() => (
window.location.href = `/posts/${json.slug || json.id}` window.location.href = `/posts/${json.slug || json.id}`
), 1000); ), 1000);
} else {
this.setState({
success: I18n.t('board.new_post.submit_pending'),
title: '',
description: '',
showForm: false,
});
}
} else { } else {
this.setState({error: json.error}); this.setState({error: json.error});
} }
@@ -131,7 +175,13 @@ class NewPost extends React.Component<Props, State> {
} }
render() { render() {
const { board, isLoggedIn } = this.props; const {
board,
isLoggedIn,
currentUserFullName,
isAnonymousFeedbackAllowed
} = this.props;
const { const {
showForm, showForm,
error, error,
@@ -139,7 +189,11 @@ class NewPost extends React.Component<Props, State> {
isLoading, isLoading,
title, title,
description description,
isSubmissionAnonymous,
dnf1,
dnf2,
} = this.state; } = this.state;
return ( return (
@@ -154,12 +208,24 @@ class NewPost extends React.Component<Props, State> {
{board.description} {board.description}
</ReactMarkdown> </ReactMarkdown>
{
isLoggedIn ?
<Button <Button
onClick={this.toggleForm} onClick={() => {
if (showForm) {
this.toggleForm();
return;
}
if (isLoggedIn) {
this.toggleForm();
this.setState({ isSubmissionAnonymous: false });
} else {
window.location.href = '/users/sign_in';
}
}}
className="submitBtn" className="submitBtn"
outline={showForm}> outline={showForm}
>
{ {
showForm ? showForm ?
I18n.t('board.new_post.cancel_button') I18n.t('board.new_post.cancel_button')
@@ -167,10 +233,22 @@ class NewPost extends React.Component<Props, State> {
I18n.t('board.new_post.submit_button') I18n.t('board.new_post.submit_button')
} }
</Button> </Button>
:
<a href="/users/sign_in" className="btn btnPrimary"> {
{I18n.t('board.new_post.login_button')} (isAnonymousFeedbackAllowed && !showForm) &&
<div className="anonymousFeedbackLink">
{I18n.t('common.words.or')}
&nbsp;
<a
onClick={() => {
this.toggleForm();
this.setState({ isSubmissionAnonymous: true });
}}
className="link"
>
{I18n.t('board.new_post.submit_anonymous_button').toLowerCase()}
</a> </a>
</div>
} }
{ {
@@ -180,7 +258,16 @@ class NewPost extends React.Component<Props, State> {
description={description} description={description}
handleTitleChange={this.onTitleChange} handleTitleChange={this.onTitleChange}
handleDescriptionChange={this.onDescriptionChange} handleDescriptionChange={this.onDescriptionChange}
handleSubmit={this.submitForm} handleSubmit={this.submitForm}
dnf1={dnf1}
dnf2={dnf2}
handleDnf1Change={this.onDnf1Change}
handleDnf2Change={this.onDnf2Change}
currentUserFullName={currentUserFullName}
isSubmissionAnonymous={isSubmissionAnonymous}
/> />
: :
null null

View File

@@ -2,13 +2,23 @@ import * as React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Button from '../common/Button'; import Button from '../common/Button';
import { SmallMutedText } from '../common/CustomTexts';
interface Props { interface Props {
title: string; title: string;
description: string; description: string;
handleTitleChange(title: string): void; handleTitleChange(title: string): void;
handleDescriptionChange(description: string): void; handleDescriptionChange(description: string): void;
handleSubmit(e: object): void; handleSubmit(e: object): void;
dnf1: string;
dnf2: string;
handleDnf1Change(dnf1: string): void;
handleDnf2Change(dnf2: string): void;
currentUserFullName: string;
isSubmissionAnonymous: boolean;
} }
const NewPostForm = ({ const NewPostForm = ({
@@ -16,17 +26,26 @@ const NewPostForm = ({
description, description,
handleTitleChange, handleTitleChange,
handleDescriptionChange, handleDescriptionChange,
handleSubmit, handleSubmit,
dnf1,
dnf2,
handleDnf1Change,
handleDnf2Change,
currentUserFullName,
isSubmissionAnonymous,
}: Props) => ( }: Props) => (
<div className="newPostForm"> <div className="newPostForm">
<form> <form>
<div className="form-group"> <div className="form-group">
<label htmlFor="postTitle">{I18n.t('board.new_post.title')}</label>
<input <input
type="text" type="text"
value={title} value={title}
onChange={e => handleTitleChange(e.target.value)} onChange={e => handleTitleChange(e.target.value)}
maxLength={128} maxLength={128}
placeholder={I18n.t('board.new_post.title')}
id="postTitle" id="postTitle"
className="form-control" className="form-control"
@@ -34,20 +53,60 @@ const NewPostForm = ({
autoFocus autoFocus
/> />
</div> </div>
{ /* Honeypot field 1 */ }
<div className="form-group form-group-dnf">
<input
type="text"
value={dnf1}
onChange={e => handleDnf1Change(e.target.value)}
maxLength={128}
placeholder="email"
autoComplete="off"
id="email"
className="form-control"
/>
</div>
{ /* Honeypot field 2 */ }
<input
type="text"
value={dnf2}
onChange={e => handleDnf2Change(e.target.value)}
maxLength={128}
placeholder="name"
autoComplete="off"
tabIndex={-1}
id="name"
className="form-control"
/>
<div className="form-group"> <div className="form-group">
<label htmlFor="postDescription">{I18n.t('board.new_post.description')}</label>
<textarea <textarea
value={description} value={description}
onChange={e => handleDescriptionChange(e.target.value)} onChange={e => handleDescriptionChange(e.target.value)}
rows={3} rows={3}
placeholder={I18n.t('board.new_post.description')}
className="form-control" className="form-control"
id="postDescription" id="postDescription"
></textarea> ></textarea>
</div> </div>
<Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto"> <Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto">
{I18n.t('board.new_post.submit_button')} {I18n.t('board.new_post.submit_button')}
</Button> </Button>
<SmallMutedText>
{
isSubmissionAnonymous ?
I18n.t('board.new_post.anonymous_submission_help')
:
I18n.t('board.new_post.non_anonymous_submission_help', { name: currentUserFullName })
}
</SmallMutedText>
</form> </form>
</div> </div>
); );

View File

@@ -14,7 +14,9 @@ interface Props {
board: IBoard; board: IBoard;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
currentUserFullName: string;
tenantSetting: ITenantSetting; tenantSetting: ITenantSetting;
componentRenderedAt: number;
authenticityToken: string; authenticityToken: string;
} }
@@ -32,7 +34,9 @@ class BoardRoot extends React.Component<Props> {
board, board,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
currentUserFullName,
tenantSetting, tenantSetting,
componentRenderedAt,
authenticityToken, authenticityToken,
} = this.props; } = this.props;
@@ -42,7 +46,9 @@ class BoardRoot extends React.Component<Props> {
board={board} board={board}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
currentUserFullName={currentUserFullName}
tenantSetting={tenantSetting} tenantSetting={tenantSetting}
componentRenderedAt={componentRenderedAt}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
</Provider> </Provider>

View File

@@ -0,0 +1,70 @@
import React from 'react';
import I18n from 'i18n-js';
import Gravatar from 'react-gravatar';
import IPost, { PostApprovalStatus } from '../../../interfaces/IPost';
import { AnonymousIcon, ApproveIcon, RejectIcon } from '../../common/Icons';
import ReactMarkdown from 'react-markdown';
import ActionLink from '../../common/ActionLink';
interface Props {
post: IPost;
onUpdatePostApprovalStatus(
id: number,
approvalStatus: PostApprovalStatus,
): Promise<any>;
hideRejectButton: boolean;
}
const FeedbackListItem = ({ post, onUpdatePostApprovalStatus, hideRejectButton }: Props) => {
return (
<div className="feedbackListItem">
<div className="feedbackListItemIconAndContent">
<div className="feedbackListItemIcon">
{
post.userId ?
<Gravatar email={post.userEmail} size={42} title={post.userEmail} className="gravatar userGravatar" />
:
<AnonymousIcon size={42} />
}
</div>
<div className="feedbackListItemContent">
<p className="feedbackListItemTitle" onClick={() => window.location.href = `/posts/${post.slug || post.id}`}>
{post.title}
</p>
<ReactMarkdown className="feedbackListItemDescription" allowedTypes={['text']} unwrapDisallowed>
{post.description.length > 200 ? `${post.description.slice(0, 200)}...` : post.description}
</ReactMarkdown>
</div>
</div>
<div className="feedbackListItemActions">
<ActionLink
onClick={() => {
onUpdatePostApprovalStatus(post.id, 'approved')
}}
icon={<ApproveIcon />}
>
{I18n.t('common.buttons.approve')}
</ActionLink>
{!hideRejectButton &&
<ActionLink
onClick={() => {
onUpdatePostApprovalStatus(post.id, 'rejected')
}}
icon={<RejectIcon />}
>
{I18n.t('common.buttons.reject')}
</ActionLink>
}
</div>
</div>
);
};
export default FeedbackListItem;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import I18n from 'i18n-js';
import IPost, { PostApprovalStatus } from '../../../interfaces/IPost';
import FeedbackListItem from './FeedbackListItem';
import { MutedText } from '../../common/CustomTexts';
interface Props {
posts: Array<IPost>;
onUpdatePostApprovalStatus(
id: number,
approvalStatus: PostApprovalStatus,
): Promise<any>;
hideRejectButton?: boolean;
}
const FeedbackModerationList = ({ posts, onUpdatePostApprovalStatus, hideRejectButton = false }: Props) => {
return (
<div className="feedbackModerationList">
{
(posts && posts.length > 0) ?
posts.map((post, i) => (
<FeedbackListItem
key={i}
post={post}
onUpdatePostApprovalStatus={onUpdatePostApprovalStatus}
hideRejectButton={hideRejectButton}
/>
))
:
<div className="emptyList">
<MutedText>{I18n.t('board.posts_list.empty')}</MutedText>
</div>
}
</div>
);
};
export default FeedbackModerationList;

View File

@@ -0,0 +1,157 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import { UserRoles } from '../../../interfaces/IUser';
import { TenantSettingFeedbackApprovalPolicy } from '../../../interfaces/ITenantSetting';
import Badge, { BADGE_TYPE_LIGHT } from '../../common/Badge';
import ActionLink from '../../common/ActionLink';
import { SettingsIcon } from '../../common/Icons';
import IPost, { PostApprovalStatus } from '../../../interfaces/IPost';
import FeedbackModerationList from './FeedbackModerationList';
interface Props {
currentUserRole: UserRoles;
changeFeedbackModerationSettingsUrl: string;
tenantSettingAllowAnonymousFeedback: boolean;
tenantSettingFeedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy;
authenticityToken: string;
posts: Array<IPost>;
areLoading: boolean;
areUpdating: boolean;
error: string;
requestPostsForModeration(): void;
updatePostApprovalStatus(
id: number,
approvalStatus: PostApprovalStatus,
authenticityToken: string
): Promise<any>;
}
interface State {
filter: 'pending' | 'rejected';
}
class FeedbackModerationP extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
filter: 'pending',
};
}
componentDidMount() {
this.props.requestPostsForModeration();
}
getFeedbackPolicyString(tenantSettingFeedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy): string {
switch (tenantSettingFeedbackApprovalPolicy) {
case 'anonymous_require_approval':
return I18n.t('site_settings.general.feedback_approval_policy_anonymous_require_approval');
case 'never_require_approval':
return I18n.t('site_settings.general.feedback_approval_policy_never_require_approval');
case 'always_require_approval':
return I18n.t('site_settings.general.feedback_approval_policy_always_require_approval');
}
}
render() {
const {
currentUserRole,
changeFeedbackModerationSettingsUrl,
tenantSettingAllowAnonymousFeedback,
tenantSettingFeedbackApprovalPolicy,
posts,
areLoading,
areUpdating,
error,
updatePostApprovalStatus,
authenticityToken,
} = this.props;
const { filter } = this.state;
const pendingPosts = posts.filter(post => post.approvalStatus === 'pending');
const rejectedPosts = posts.filter(post => post.approvalStatus === 'rejected');
return (
<>
<Box customClass="feedbackModerationContainer">
<h2>{ I18n.t('moderation.menu.feedback') }</h2>
{
(currentUserRole === 'admin' || currentUserRole === 'owner') &&
<>
<div className="badges">
<Badge type={BADGE_TYPE_LIGHT}>
{
tenantSettingAllowAnonymousFeedback ?
I18n.t('moderation.feedback.anonymous_feedback_allowed')
:
I18n.t('moderation.feedback.anonymous_feedback_not_allowed')
}
</Badge>
<Badge type={BADGE_TYPE_LIGHT}>
{ this.getFeedbackPolicyString(tenantSettingFeedbackApprovalPolicy) }
</Badge>
</div>
<ActionLink
onClick={() => window.location.href = changeFeedbackModerationSettingsUrl} icon={<SettingsIcon />}
customClass="changeFeedbackModerationSettingsLink"
>
{ I18n.t('moderation.feedback.change_feedback_moderation_settings') }
</ActionLink>
</>
}
<ul className="filterModerationFeedbackNav">
<li className="nav-item">
<a onClick={() => this.setState({filter: 'pending'})} className={`nav-link${filter === 'pending' ? ' active' : ''}`}>
{I18n.t('activerecord.attributes.post.approval_status_pending')}
&nbsp;
({pendingPosts && pendingPosts.length})
</a>
</li>
<li className="nav-item">
<a onClick={() => this.setState({filter: 'rejected'})} className={`nav-link${filter === 'rejected' ? ' active' : ''}`}>
{I18n.t('activerecord.attributes.post.approval_status_rejected')}
</a>
</li>
</ul>
{
filter === 'pending' ?
<FeedbackModerationList
posts={pendingPosts}
onUpdatePostApprovalStatus={
(id: number, approvalStatus: PostApprovalStatus) =>
updatePostApprovalStatus(id, approvalStatus, authenticityToken)
}
/>
:
<FeedbackModerationList
posts={rejectedPosts}
onUpdatePostApprovalStatus={
(id: number, approvalStatus: PostApprovalStatus) =>
updatePostApprovalStatus(id, approvalStatus, authenticityToken)
}
hideRejectButton
/>
}
</Box>
<SiteSettingsInfoBox areUpdating={areLoading || areUpdating} error={error} />
</>
);
}
}
export default FeedbackModerationP;

View File

@@ -0,0 +1,44 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import FeedbackModeration from '../../../containers/FeedbackModeration';
import createStoreHelper from '../../../helpers/createStore';
import { State } from '../../../reducers/rootReducer';
import { UserRoles } from '../../../interfaces/IUser';
import { TenantSettingFeedbackApprovalPolicy } from '../../../interfaces/ITenantSetting';
interface Props {
currentUserRole: UserRoles;
changeFeedbackModerationSettingsUrl: string;
tenantSettingAllowAnonymousFeedback: boolean;
tenantSettingFeedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy;
authenticityToken: string;
}
class FeedbackModerationRoot extends React.Component<Props> {
store: Store<State, any>;
constructor(props: Props) {
super(props);
this.store = createStoreHelper();
}
render() {
return (
<Provider store={this.store}>
<FeedbackModeration
currentUserRole={this.props.currentUserRole}
changeFeedbackModerationSettingsUrl={this.props.changeFeedbackModerationSettingsUrl}
tenantSettingAllowAnonymousFeedback={this.props.tenantSettingAllowAnonymousFeedback}
tenantSettingFeedbackApprovalPolicy={this.props.tenantSettingFeedbackApprovalPolicy}
authenticityToken={this.props.authenticityToken}
/>
</Provider>
);
}
}
export default FeedbackModerationRoot;

View File

@@ -64,9 +64,9 @@ class UserEditable extends React.Component<Props, State> {
const confirmationMessage = const confirmationMessage =
newStatus === 'blocked' ? newStatus === 'blocked' ?
I18n.t('site_settings.users.block_confirmation', { name: user.fullName }) I18n.t('moderation.users.block_confirmation', { name: user.fullName })
: :
I18n.t('site_settings.users.unblock_confirmation', { name: user.fullName }); I18n.t('moderation.users.unblock_confirmation', { name: user.fullName });
const confirmationResponse = confirm(confirmationMessage); const confirmationResponse = confirm(confirmationMessage);
@@ -101,7 +101,7 @@ class UserEditable extends React.Component<Props, State> {
<div className="userRoleStatus"> <div className="userRoleStatus">
<span> <span>
<MutedText>{ I18n.t(`site_settings.users.role_${user.role}`) }</MutedText> <MutedText>{ I18n.t(`moderation.users.role_${user.role}`) }</MutedText>
</span> </span>
{ {
@@ -109,7 +109,7 @@ class UserEditable extends React.Component<Props, State> {
<> <>
<Separator /> <Separator />
<span className={`userStatus userStatus${user.status}`}> <span className={`userStatus userStatus${user.status}`}>
{ I18n.t(`site_settings.users.status_${user.status}`) } { I18n.t(`moderation.users.status_${user.status}`) }
</span> </span>
</> </>
: :
@@ -137,9 +137,9 @@ class UserEditable extends React.Component<Props, State> {
> >
{ {
user.status !== USER_STATUS_BLOCKED ? user.status !== USER_STATUS_BLOCKED ?
I18n.t('site_settings.users.block') I18n.t('moderation.users.block')
: :
I18n.t('site_settings.users.unblock') I18n.t('moderation.users.unblock')
} }
</ActionLink> </ActionLink>
</div> </div>

View File

@@ -30,9 +30,9 @@ class UserForm extends React.Component<Props, State> {
if (selectedRole !== currentRole) { if (selectedRole !== currentRole) {
if (selectedRole === 'moderator') if (selectedRole === 'moderator')
confirmation = confirm(I18n.t('site_settings.users.role_to_moderator_confirmation', { name: user.fullName })); confirmation = confirm(I18n.t('moderation.users.role_to_moderator_confirmation', { name: user.fullName }));
else if (selectedRole === 'admin') else if (selectedRole === 'admin')
confirmation = confirm(I18n.t('site_settings.users.role_to_admin_confirmation', { name: user.fullName })); confirmation = confirm(I18n.t('moderation.users.role_to_admin_confirmation', { name: user.fullName }));
} }
if (confirmation) updateUserRole(selectedRole); if (confirmation) updateUserRole(selectedRole);
@@ -60,13 +60,13 @@ class UserForm extends React.Component<Props, State> {
> >
<optgroup label={getLabel('user', 'role')}> <optgroup label={getLabel('user', 'role')}>
<option value={USER_ROLE_USER}> <option value={USER_ROLE_USER}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_USER}`) } { I18n.t(`moderation.users.role_${USER_ROLE_USER}`) }
</option> </option>
<option value={USER_ROLE_MODERATOR}> <option value={USER_ROLE_MODERATOR}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_MODERATOR}`) } { I18n.t(`moderation.users.role_${USER_ROLE_MODERATOR}`) }
</option> </option>
<option value={USER_ROLE_ADMIN}> <option value={USER_ROLE_ADMIN}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_ADMIN}`) } { I18n.t(`moderation.users.role_${USER_ROLE_ADMIN}`) }
</option> </option>
</optgroup> </optgroup>
</select> </select>

View File

@@ -32,7 +32,7 @@ interface Props {
authenticityToken: string; authenticityToken: string;
} }
class UsersSiteSettingsP extends React.Component<Props> { class UsersModerationP extends React.Component<Props> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@@ -81,14 +81,14 @@ class UsersSiteSettingsP extends React.Component<Props> {
return ( return (
<> <>
<Box> <Box>
<h2>{ I18n.t('site_settings.users.title') }</h2> <h2>{ I18n.t('moderation.users.title') }</h2>
<p className="userCount"> <p className="userCount">
{numberOfUsers} {I18n.t('activerecord.models.user', {count: users.items.length})} {numberOfUsers} {I18n.t('activerecord.models.user', {count: users.items.length})}
&nbsp;( &nbsp;(
{numberOfActiveUsers} {I18n.t('site_settings.users.status_active')},&nbsp; {numberOfActiveUsers} {I18n.t('moderation.users.status_active')},&nbsp;
{numberOfBlockedUsers} {I18n.t('site_settings.users.status_blocked')},&nbsp; {numberOfBlockedUsers} {I18n.t('moderation.users.status_blocked')},&nbsp;
{numberOfDeletedUsers} {I18n.t('site_settings.users.status_deleted')}) {numberOfDeletedUsers} {I18n.t('moderation.users.status_deleted')})
</p> </p>
<ul className="usersList"> <ul className="usersList">
@@ -117,4 +117,4 @@ class UsersSiteSettingsP extends React.Component<Props> {
} }
} }
export default UsersSiteSettingsP; export default UsersModerationP;

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { Store } from 'redux'; import { Store } from 'redux';
import UsersSiteSettings from '../../../containers/UsersSiteSettings'; import UsersModeration from '../../../containers/UsersModeration';
import createStoreHelper from '../../../helpers/createStore'; import createStoreHelper from '../../../helpers/createStore';
import { UserRoles } from '../../../interfaces/IUser'; import { UserRoles } from '../../../interfaces/IUser';
@@ -14,7 +14,7 @@ interface Props {
authenticityToken: string; authenticityToken: string;
} }
class UsersSiteSettingsRoot extends React.Component<Props> { class UsersModerationRoot extends React.Component<Props> {
store: Store<State, any>; store: Store<State, any>;
constructor(props: Props) { constructor(props: Props) {
@@ -26,7 +26,7 @@ class UsersSiteSettingsRoot extends React.Component<Props> {
render() { render() {
return ( return (
<Provider store={this.store}> <Provider store={this.store}>
<UsersSiteSettings <UsersModeration
currentUserEmail={this.props.currentUserEmail} currentUserEmail={this.props.currentUserEmail}
currentUserRole={this.props.currentUserRole} currentUserRole={this.props.currentUserRole}
authenticityToken={this.props.authenticityToken} authenticityToken={this.props.authenticityToken}
@@ -36,4 +36,4 @@ class UsersSiteSettingsRoot extends React.Component<Props> {
} }
} }
export default UsersSiteSettingsRoot; export default UsersModerationRoot;

View File

@@ -29,14 +29,23 @@ const PostFooter = ({
}: Props) => ( }: Props) => (
<div className="postFooter"> <div className="postFooter">
<div className="postAuthor"> <div className="postAuthor">
{
authorEmail ?
<>
<span>{I18n.t('post.published_by').toLowerCase()} &nbsp;</span> <span>{I18n.t('post.published_by').toLowerCase()} &nbsp;</span>
<Gravatar email={authorEmail} size={24} className="postAuthorAvatar" /> &nbsp; <Gravatar email={authorEmail} size={24} className="postAuthorAvatar" /> &nbsp;
{authorFullName} <span>{authorFullName}</span>
</>
:
<span>{I18n.t('post.published_anonymously').toLowerCase()}</span>
}
<Separator /> <Separator />
{friendlyDate(createdAt)}
<span>{friendlyDate(createdAt)}</span>
</div> </div>
{ {
isPowerUser || authorEmail === currentUserEmail ? isPowerUser || (authorEmail && authorEmail === currentUserEmail) ?
<div className="postFooterActions"> <div className="postFooterActions">
<ActionLink onClick={toggleEditMode} icon={<EditIcon />} customClass='editAction'> <ActionLink onClick={toggleEditMode} icon={<EditIcon />} customClass='editAction'>
{I18n.t('common.buttons.edit')} {I18n.t('common.buttons.edit')}

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import IPost from '../../interfaces/IPost'; import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING } from '../../interfaces/IPost';
import IPostStatus from '../../interfaces/IPostStatus'; import IPostStatus from '../../interfaces/IPostStatus';
import IBoard from '../../interfaces/IBoard'; import IBoard from '../../interfaces/IBoard';
import ITenantSetting from '../../interfaces/ITenantSetting'; import ITenantSetting from '../../interfaces/ITenantSetting';
@@ -28,6 +28,7 @@ import { fromRailsStringToJavascriptDate } from '../../helpers/datetime';
import HttpStatus from '../../constants/http_status'; import HttpStatus from '../../constants/http_status';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { EditIcon } from '../common/Icons'; import { EditIcon } from '../common/Icons';
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_WARNING } from '../common/Badge';
interface Props { interface Props {
postId: number; postId: number;
@@ -250,6 +251,15 @@ class PostP extends React.Component<Props> {
} }
</div> </div>
{
(isPowerUser && post.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) &&
<div className="postInfo">
<Badge type={post.approvalStatus === POST_APPROVAL_STATUS_PENDING ? BADGE_TYPE_WARNING : BADGE_TYPE_DANGER}>
{ I18n.t(`activerecord.attributes.post.approval_status_${post.approvalStatus.toLowerCase()}`) }
</Badge>
</div>
}
<ReactMarkdown <ReactMarkdown
className="postDescription" className="postDescription"
disallowedTypes={['heading', 'image', 'html']} disallowedTypes={['heading', 'image', 'html']}

View File

@@ -25,13 +25,15 @@ export interface ISiteSettingsGeneralForm {
siteLogo: string; siteLogo: string;
brandDisplaySetting: string; brandDisplaySetting: string;
locale: string; locale: string;
rootBoardId?: string;
customDomain?: string;
allowAnonymousFeedback: boolean;
feedbackApprovalPolicy: string;
showRoadmapInHeader: boolean;
collapseBoardsInHeader: string;
showVoteCount: boolean; showVoteCount: boolean;
showVoteButtonInBoard: boolean; showVoteButtonInBoard: boolean;
showPoweredBy: boolean; showPoweredBy: boolean;
rootBoardId?: string;
customDomain?: string;
showRoadmapInHeader: boolean;
collapseBoardsInHeader: string;
} }
interface Props { interface Props {
@@ -50,6 +52,8 @@ interface Props {
locale: string, locale: string,
rootBoardId: number, rootBoardId: number,
customDomain: string, customDomain: string,
allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: string,
showRoadmapInHeader: boolean, showRoadmapInHeader: boolean,
collapseBoardsInHeader: string, collapseBoardsInHeader: string,
showVoteCount: boolean, showVoteCount: boolean,
@@ -80,13 +84,15 @@ const GeneralSiteSettingsP = ({
siteLogo: originForm.siteLogo, siteLogo: originForm.siteLogo,
brandDisplaySetting: originForm.brandDisplaySetting, brandDisplaySetting: originForm.brandDisplaySetting,
locale: originForm.locale, locale: originForm.locale,
rootBoardId: originForm.rootBoardId,
customDomain: originForm.customDomain,
allowAnonymousFeedback: originForm.allowAnonymousFeedback,
feedbackApprovalPolicy: originForm.feedbackApprovalPolicy,
showRoadmapInHeader: originForm.showRoadmapInHeader,
collapseBoardsInHeader: originForm.collapseBoardsInHeader,
showVoteCount: originForm.showVoteCount, showVoteCount: originForm.showVoteCount,
showVoteButtonInBoard: originForm.showVoteButtonInBoard, showVoteButtonInBoard: originForm.showVoteButtonInBoard,
showPoweredBy: originForm.showPoweredBy, showPoweredBy: originForm.showPoweredBy,
rootBoardId: originForm.rootBoardId,
customDomain: originForm.customDomain,
showRoadmapInHeader: originForm.showRoadmapInHeader,
collapseBoardsInHeader: originForm.collapseBoardsInHeader,
}, },
}); });
@@ -98,6 +104,8 @@ const GeneralSiteSettingsP = ({
data.locale, data.locale,
Number(data.rootBoardId), Number(data.rootBoardId),
data.customDomain, data.customDomain,
data.allowAnonymousFeedback,
data.feedbackApprovalPolicy,
data.showRoadmapInHeader, data.showRoadmapInHeader,
data.collapseBoardsInHeader, data.collapseBoardsInHeader,
data.showVoteCount, data.showVoteCount,
@@ -106,15 +114,35 @@ const GeneralSiteSettingsP = ({
authenticityToken authenticityToken
).then(res => { ).then(res => {
if (res?.status !== HttpStatus.OK) return; if (res?.status !== HttpStatus.OK) return;
const urlWithoutHash = window.location.href.split('#')[0];
window.history.pushState({}, document.title, urlWithoutHash);
window.location.reload(); window.location.reload();
}); });
}; };
const customDomain = watch('customDomain'); const customDomain = watch('customDomain');
React.useEffect(() => {
if (window.location.hash) {
const anchor = window.location.hash.substring(1);
const anchorElement = document.getElementById(anchor);
if (anchorElement) {
anchorElement.classList.add('highlighted');
setTimeout( () => {
anchorElement.scrollIntoView({
behavior: 'smooth'
})
}, 50);
}
}
}, []);
return ( return (
<> <>
<Box> <Box customClass="generalSiteSettingsContainer">
<h2>{ I18n.t('site_settings.general.title') }</h2> <h2>{ I18n.t('site_settings.general.title') }</h2>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
@@ -226,7 +254,43 @@ const GeneralSiteSettingsP = ({
</div> </div>
} }
<br /> <div id="moderation" className="settingsGroup">
<h4>{ I18n.t('site_settings.general.subtitle_moderation') }</h4>
<div className="formGroup">
<div className="checkboxSwitch">
<input {...register('allowAnonymousFeedback')} type="checkbox" id="allow_anonymous_feedback" />
<label htmlFor="allow_anonymous_feedback">{ getLabel('tenant_setting', 'allow_anonymous_feedback') }</label>
<SmallMutedText>
{ I18n.t('site_settings.general.allow_anonymous_feedback_help') }
</SmallMutedText>
</div>
</div>
<div className="formGroup">
<label htmlFor="feedbackApprovalPolicy">{ getLabel('tenant_setting', 'feedback_approval_policy') }</label>
<select
{...register('feedbackApprovalPolicy')}
id="feedbackApprovalPolicy"
className="selectPicker"
>
<option value="anonymous_require_approval">
{ I18n.t('site_settings.general.feedback_approval_policy_anonymous_require_approval') }
</option>
<option value="never_require_approval">
{ I18n.t('site_settings.general.feedback_approval_policy_never_require_approval') }
</option>
<option value="always_require_approval">
{ I18n.t('site_settings.general.feedback_approval_policy_always_require_approval') }
</option>
</select>
<SmallMutedText>
{ I18n.t('site_settings.general.feedback_approval_policy_help') }
</SmallMutedText>
</div>
</div>
<div id="header" className="settingsGroup">
<h4>{ I18n.t('site_settings.general.subtitle_header') }</h4> <h4>{ I18n.t('site_settings.general.subtitle_header') }</h4>
<div className="formGroup"> <div className="formGroup">
@@ -236,7 +300,6 @@ const GeneralSiteSettingsP = ({
</div> </div>
</div> </div>
<br />
<div className="formGroup"> <div className="formGroup">
<label htmlFor="collapseBoardsInHeader">{ getLabel('tenant_setting', 'collapse_boards_in_header') }</label> <label htmlFor="collapseBoardsInHeader">{ getLabel('tenant_setting', 'collapse_boards_in_header') }</label>
<select <select
@@ -252,7 +315,9 @@ const GeneralSiteSettingsP = ({
</option> </option>
</select> </select>
</div> </div>
</div>
<div id="visibility" className="settingsGroup">
<br /> <br />
<h4>{ I18n.t('site_settings.general.subtitle_visibility') }</h4> <h4>{ I18n.t('site_settings.general.subtitle_visibility') }</h4>
@@ -266,8 +331,6 @@ const GeneralSiteSettingsP = ({
</div> </div>
</div> </div>
<br />
<div className="formGroup"> <div className="formGroup">
<div className="checkboxSwitch"> <div className="checkboxSwitch">
<input {...register('showVoteButtonInBoard')} type="checkbox" id="show_vote_button_in_board_checkbox" /> <input {...register('showVoteButtonInBoard')} type="checkbox" id="show_vote_button_in_board_checkbox" />
@@ -278,14 +341,13 @@ const GeneralSiteSettingsP = ({
</div> </div>
</div> </div>
<br />
<div className="formGroup"> <div className="formGroup">
<div className="checkboxSwitch"> <div className="checkboxSwitch">
<input {...register('showPoweredBy')} type="checkbox" id="show_powered_by_checkbox" /> <input {...register('showPoweredBy')} type="checkbox" id="show_powered_by_checkbox" />
<label htmlFor="show_powered_by_checkbox">{ getLabel('tenant_setting', 'show_powered_by') }</label> <label htmlFor="show_powered_by_checkbox">{ getLabel('tenant_setting', 'show_powered_by') }</label>
</div> </div>
</div> </div>
</div>
<br /> <br />

View File

@@ -43,7 +43,13 @@ const Tour = ({ userFullName }: Props) => {
{ {
target: '.siteSettingsDropdown', target: '.siteSettingsDropdown',
title: 'Site settings', title: 'Site settings',
content: 'Click "Site settings" to customize your feedback space. You can add custom boards and statuses, manage users, personalize appearance, and more.', content: 'Click "Site settings" to customize your feedback space. You can add custom boards and statuses, configure various settings, personalize appearance, and more.',
disableBeacon: true,
},
{
target: '.moderationDropdown',
title: 'Moderation',
content: 'Click "Moderation" to approve or reject submitted feedback and to manage users.',
disableBeacon: true, disableBeacon: true,
}, },
{ {
@@ -107,8 +113,8 @@ const Tour = ({ userFullName }: Props) => {
// Open profile navbar // Open profile navbar
if ( if (
state.type === 'step:after' && state.type === 'step:after' &&
(((state.action === 'next' || state.action === 'close') && (state.step.target === '.sidebarFilters' || state.step.target === '.siteSettingsDropdown')) || (((state.action === 'next' || state.action === 'close') && (state.step.target === '.sidebarFilters' || state.step.target === '.siteSettingsDropdown' || state.step.target === '.moderationDropdown')) ||
(state.action === 'prev' && state.step.target === '.tourDropdown')) (state.action === 'prev' && (state.step.target === '.moderationDropdown' || state.step.target === '.tourDropdown')))
) { ) {
if (vw < BOOTSTRAP_BREAKPOINT_SM) openBoardsNav(); if (vw < BOOTSTRAP_BREAKPOINT_SM) openBoardsNav();

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
export const BADGE_TYPE_LIGHT = 'badgeLight';
export const BADGE_TYPE_WARNING = 'badgeWarning';
export const BADGE_TYPE_DANGER = 'badgeDanger';
export type BadgeTypes =
typeof BADGE_TYPE_LIGHT |
typeof BADGE_TYPE_WARNING |
typeof BADGE_TYPE_DANGER;
interface Props {
type: BadgeTypes;
children: string;
}
const Badge = ({ type, children }: Props) => (
<span className={`badge ${type}`}>
{children}
</span>
);
export default Badge;

View File

@@ -2,14 +2,21 @@ import * as React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import { BsReply } from 'react-icons/bs'; import { BsReply } from 'react-icons/bs';
import { FiEdit, FiDelete } from 'react-icons/fi'; import { FiEdit, FiDelete, FiSettings } from 'react-icons/fi';
import { ImCancelCircle } from 'react-icons/im'; import { ImCancelCircle } from 'react-icons/im';
import { TbLock, TbLockOpen } from 'react-icons/tb'; import { TbLock, TbLockOpen } from 'react-icons/tb';
import { MdContentCopy, MdDone, MdOutlineArrowBack } from 'react-icons/md';
import { GrTest, GrClearOption } from 'react-icons/gr'; import { GrTest, GrClearOption } from 'react-icons/gr';
import { MdOutlineLibraryBooks } from "react-icons/md";
import { MdVerified } from "react-icons/md";
import { BiLike, BiSolidLike } from "react-icons/bi"; import { BiLike, BiSolidLike } from "react-icons/bi";
import {
MdContentCopy,
MdDone,
MdOutlineArrowBack,
MdOutlineLibraryBooks,
MdVerified,
MdCheck,
MdClear,
} from 'react-icons/md';
import { FaUserNinja } from "react-icons/fa";
export const EditIcon = () => <FiEdit />; export const EditIcon = () => <FiEdit />;
@@ -44,3 +51,11 @@ export const ClearIcon = () => <GrClearOption />;
export const LikeIcon = ({size = 32}) => <BiLike size={size} />; export const LikeIcon = ({size = 32}) => <BiLike size={size} />;
export const SolidLikeIcon = ({size = 32}) => <BiSolidLike size={size} />; export const SolidLikeIcon = ({size = 32}) => <BiSolidLike size={size} />;
export const SettingsIcon = () => <FiSettings />;
export const AnonymousIcon = ({size = 32, title=I18n.t('defaults.user_full_name')}) => <FaUserNinja size={size} title={title} />;
export const ApproveIcon = () => <MdCheck />;
export const RejectIcon = () => <MdClear />;

View File

@@ -62,7 +62,6 @@ const mapDispatchToProps = (dispatch: any) => ({
deleteBoard(id: number, authenticityToken: string) { deleteBoard(id: number, authenticityToken: string) {
dispatch(deleteBoard(id, authenticityToken)).then(res => { dispatch(deleteBoard(id, authenticityToken)).then(res => {
console.log(res);
if (res && res.status === HttpStatus.Accepted) { if (res && res.status === HttpStatus.Accepted) {
window.location.reload(); window.location.reload();
} }

View File

@@ -0,0 +1,34 @@
import { connect } from "react-redux";
import FeedbackModerationP from "../components/Moderation/Feedback/FeedbackModerationP";
import { State } from "../reducers/rootReducer";
import { requestPostsForModeration } from "../actions/Post/requestPosts";
import { updatePostApprovalStatus } from "../actions/Post/updatePost";
import { PostApprovalStatus } from "../interfaces/IPost";
const mapStateToProps = (state: State) => ({
posts: state.moderation.feedback.posts,
areLoading: state.moderation.feedback.areLoading,
areUpdating: state.moderation.feedback.areUpdating,
error: state.moderation.feedback.error,
});
const mapDispatchToProps = (dispatch: any) => ({
requestPostsForModeration() {
dispatch(requestPostsForModeration());
},
updatePostApprovalStatus(
id: number,
approvalStatus: PostApprovalStatus,
authenticityToken: string
) {
return dispatch(updatePostApprovalStatus(id, approvalStatus, authenticityToken));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(FeedbackModerationP);

View File

@@ -3,7 +3,7 @@ import { connect } from "react-redux";
import GeneralSiteSettingsP from "../components/SiteSettings/General/GeneralSiteSettingsP"; import GeneralSiteSettingsP from "../components/SiteSettings/General/GeneralSiteSettingsP";
import { updateTenant } from "../actions/Tenant/updateTenant"; import { updateTenant } from "../actions/Tenant/updateTenant";
import { State } from "../reducers/rootReducer"; import { State } from "../reducers/rootReducer";
import { TenantSettingBrandDisplay, TenantSettingCollapseBoardsInHeader } from "../interfaces/ITenantSetting"; import { TenantSettingBrandDisplay, TenantSettingCollapseBoardsInHeader, TenantSettingFeedbackApprovalPolicy } from "../interfaces/ITenantSetting";
const mapStateToProps = (state: State) => ({ const mapStateToProps = (state: State) => ({
areUpdating: state.siteSettings.general.areUpdating, areUpdating: state.siteSettings.general.areUpdating,
@@ -18,6 +18,8 @@ const mapDispatchToProps = (dispatch: any) => ({
locale: string, locale: string,
rootBoardId: number, rootBoardId: number,
customDomain: string, customDomain: string,
allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy,
showRoadmapInHeader: boolean, showRoadmapInHeader: boolean,
collapseBoardsInHeader: TenantSettingCollapseBoardsInHeader, collapseBoardsInHeader: TenantSettingCollapseBoardsInHeader,
showVoteCount: boolean, showVoteCount: boolean,
@@ -30,12 +32,14 @@ const mapDispatchToProps = (dispatch: any) => ({
siteLogo, siteLogo,
tenantSetting: { tenantSetting: {
brand_display: brandDisplaySetting, brand_display: brandDisplaySetting,
root_board_id: rootBoardId,
allow_anonymous_feedback: allowAnonymousFeedback,
feedback_approval_policy: feedbackApprovalPolicy,
show_roadmap_in_header: showRoadmapInHeader,
collapse_boards_in_header: collapseBoardsInHeader,
show_vote_count: showVoteCount, show_vote_count: showVoteCount,
show_vote_button_in_board: showVoteButtonInBoard, show_vote_button_in_board: showVoteButtonInBoard,
show_powered_by: showPoweredBy, show_powered_by: showPoweredBy,
root_board_id: rootBoardId,
show_roadmap_in_header: showRoadmapInHeader,
collapse_boards_in_header: collapseBoardsInHeader,
}, },
locale, locale,
customDomain, customDomain,

View File

@@ -1,6 +1,6 @@
import { connect } from "react-redux"; import { connect } from "react-redux";
import UsersSiteSettingsP from "../components/SiteSettings/Users/UsersSiteSettingsP"; import UsersModerationP from "../components/Moderation/Users/UsersModerationP";
import { requestUsers } from "../actions/User/requestUsers"; import { requestUsers } from "../actions/User/requestUsers";
import { updateUser } from "../actions/User/updateUser"; import { updateUser } from "../actions/User/updateUser";
@@ -9,8 +9,8 @@ import { State } from "../reducers/rootReducer";
const mapStateToProps = (state: State) => ({ const mapStateToProps = (state: State) => ({
users: state.users, users: state.users,
settingsAreUpdating: state.siteSettings.users.areUpdating, settingsAreUpdating: state.moderation.users.areUpdating,
settingsError: state.siteSettings.users.error, settingsError: state.moderation.users.error,
}); });
const mapDispatchToProps = (dispatch: any) => ({ const mapDispatchToProps = (dispatch: any) => ({
@@ -46,4 +46,4 @@ const mapDispatchToProps = (dispatch: any) => ({
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(UsersSiteSettingsP); )(UsersModerationP);

View File

@@ -1,8 +1,19 @@
// Approval status
export const POST_APPROVAL_STATUS_APPROVED = 'approved';
export const POST_APPROVAL_STATUS_PENDING = 'pending';
export const POST_APPROVAL_STATUS_REJECTED = 'rejected';
export type PostApprovalStatus =
typeof POST_APPROVAL_STATUS_APPROVED |
typeof POST_APPROVAL_STATUS_PENDING |
typeof POST_APPROVAL_STATUS_REJECTED;
interface IPost { interface IPost {
id: number; id: number;
title: string; title: string;
slug?: string; slug?: string;
description?: string; description?: string;
approvalStatus: PostApprovalStatus;
boardId: number; boardId: number;
postStatusId?: number; postStatusId?: number;
likeCount: number; likeCount: number;

View File

@@ -10,6 +10,16 @@ export type TenantSettingBrandDisplay =
typeof TENANT_SETTING_BRAND_DISPLAY_LOGO_ONLY | typeof TENANT_SETTING_BRAND_DISPLAY_LOGO_ONLY |
typeof TENANT_SETTING_BRAND_DISPLAY_NONE; typeof TENANT_SETTING_BRAND_DISPLAY_NONE;
// Feedback approval policy
export const TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_ANONYMOUS_REQUIRE_APPROVAL = 'anonymous_require_approval';
export const TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_NEVER_REQUIRE_APPROVAL = 'never_require_approval';
export const TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_ALWAYS_REQUIRE_APPROVAL = 'always_require_approval';
export type TenantSettingFeedbackApprovalPolicy =
typeof TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_ANONYMOUS_REQUIRE_APPROVAL |
typeof TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_NEVER_REQUIRE_APPROVAL |
typeof TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_ALWAYS_REQUIRE_APPROVAL;
// Collapse boards in header // Collapse boards in header
export const TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_NO_COLLAPSE = 'no_collapse'; export const TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_NO_COLLAPSE = 'no_collapse';
export const TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_ALWAYS_COLLAPSE = 'always_collapse'; export const TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_ALWAYS_COLLAPSE = 'always_collapse';
@@ -22,6 +32,8 @@ export type TenantSettingCollapseBoardsInHeader =
interface ITenantSetting { interface ITenantSetting {
brand_display?: TenantSettingBrandDisplay; brand_display?: TenantSettingBrandDisplay;
root_board_id?: number; root_board_id?: number;
allow_anonymous_feedback?: boolean;
feedback_approval_policy?: TenantSettingFeedbackApprovalPolicy;
show_vote_count?: boolean; show_vote_count?: boolean;
show_vote_button_in_board?: boolean; show_vote_button_in_board?: boolean;
show_roadmap_in_header?: boolean; show_roadmap_in_header?: boolean;

View File

@@ -1,8 +1,11 @@
import { PostApprovalStatus } from "../IPost";
interface IPostJSON { interface IPostJSON {
id: number; id: number;
title: string; title: string;
slug?: string; slug?: string;
description?: string; description?: string;
approval_status: PostApprovalStatus;
board_id: number; board_id: number;
post_status_id?: number; post_status_id?: number;
likes_count: number; likes_count: number;

View File

@@ -0,0 +1,67 @@
import { postRequestSuccess } from "../../actions/Post/requestPost";
import { POSTS_REQUEST_FAILURE, POSTS_REQUEST_START, POSTS_REQUEST_SUCCESS, PostsRequestActionTypes } from "../../actions/Post/requestPosts";
import { POST_UPDATE_FAILURE, POST_UPDATE_START, POST_UPDATE_SUCCESS, PostUpdateActionTypes, postUpdateSuccess } from "../../actions/Post/updatePost";
import IPost from "../../interfaces/IPost";
import postReducer from "../postReducer";
export interface ModerationFeedbackState {
posts: Array<IPost>;
areLoading: boolean;
areUpdating: boolean;
error: string;
}
const initialState: ModerationFeedbackState = {
posts: [],
areLoading: true,
areUpdating: false,
error: '',
};
const moderationFeedbackReducer = (
state = initialState,
action: PostsRequestActionTypes | PostUpdateActionTypes,
) => {
switch (action.type) {
case POSTS_REQUEST_START:
return {
...state,
areLoading: true,
};
case POST_UPDATE_START:
return {
...state,
areUpdating: true,
};
case POSTS_REQUEST_SUCCESS:
return {
...state,
areLoading: false,
error: '',
posts: action.posts.map(post => postReducer(undefined, postRequestSuccess(post))),
};
case POST_UPDATE_SUCCESS:
return {
...state,
areUpdating: false,
error: '',
posts: state.posts.map(post => post.id === action.post.id ? postReducer(post, postUpdateSuccess(action.post)) : post),
};
case POSTS_REQUEST_FAILURE:
case POST_UPDATE_FAILURE:
return {
...state,
areLoading: false,
error: action.error,
};
default:
return state;
}
}
export default moderationFeedbackReducer;

View File

@@ -5,17 +5,17 @@ import {
USER_UPDATE_FAILURE, USER_UPDATE_FAILURE,
} from '../../actions/User/updateUser'; } from '../../actions/User/updateUser';
export interface SiteSettingsUsersState { export interface ModerationUsersState {
areUpdating: boolean; areUpdating: boolean;
error: string; error: string;
} }
const initialState: SiteSettingsUsersState = { const initialState: ModerationUsersState = {
areUpdating: false, areUpdating: false,
error: '', error: '',
}; };
const siteSettingsUsersReducer = ( const moderationUsersReducer = (
state = initialState, state = initialState,
action: UserUpdateActionTypes, action: UserUpdateActionTypes,
) => { ) => {
@@ -45,4 +45,4 @@ const siteSettingsUsersReducer = (
} }
} }
export default siteSettingsUsersReducer; export default moderationUsersReducer;

View File

@@ -0,0 +1,55 @@
import { POSTS_REQUEST_FAILURE, POSTS_REQUEST_START, POSTS_REQUEST_SUCCESS, PostsRequestActionTypes } from '../actions/Post/requestPosts';
import { POST_UPDATE_FAILURE, POST_UPDATE_START, POST_UPDATE_SUCCESS, PostUpdateActionTypes } from '../actions/Post/updatePost';
import {
UserUpdateActionTypes,
USER_UPDATE_START,
USER_UPDATE_SUCCESS,
USER_UPDATE_FAILURE,
} from '../actions/User/updateUser';
import moderationFeedbackReducer, { ModerationFeedbackState } from './Moderation/feedbackReducer';
import moderationUsersReducer, { ModerationUsersState } from './Moderation/usersReducer';
interface ModerationState {
feedback: ModerationFeedbackState;
users: ModerationUsersState;
}
const initialState: ModerationState = {
feedback: moderationFeedbackReducer(undefined, {} as any),
users: moderationUsersReducer(undefined, {} as any),
};
const moderationReducer = (
state = initialState,
action:
PostsRequestActionTypes |
PostUpdateActionTypes |
UserUpdateActionTypes
): ModerationState => {
switch (action.type) {
case POSTS_REQUEST_START:
case POSTS_REQUEST_SUCCESS:
case POSTS_REQUEST_FAILURE:
case POST_UPDATE_START:
case POST_UPDATE_SUCCESS:
case POST_UPDATE_FAILURE:
return {
...state,
feedback: moderationFeedbackReducer(state.feedback, action),
};
case USER_UPDATE_START:
case USER_UPDATE_SUCCESS:
case USER_UPDATE_FAILURE:
return {
...state,
users: moderationUsersReducer(state.users, action),
};
default:
return state;
}
}
export default moderationReducer;

View File

@@ -8,13 +8,14 @@ import {
POST_UPDATE_SUCCESS, POST_UPDATE_SUCCESS,
} from '../actions/Post/updatePost'; } from '../actions/Post/updatePost';
import IPost from '../interfaces/IPost'; import IPost, { POST_APPROVAL_STATUS_APPROVED } from '../interfaces/IPost';
const initialState: IPost = { const initialState: IPost = {
id: 0, id: 0,
title: '', title: '',
slug: null, slug: null,
description: null, description: null,
approvalStatus: POST_APPROVAL_STATUS_APPROVED,
boardId: 0, boardId: 0,
postStatusId: null, postStatusId: null,
likeCount: 0, likeCount: 0,
@@ -40,6 +41,7 @@ const postReducer = (
title: action.post.title, title: action.post.title,
slug: action.post.slug, slug: action.post.slug,
description: action.post.description, description: action.post.description,
approvalStatus: action.post.approval_status,
boardId: action.post.board_id, boardId: action.post.board_id,
postStatusId: action.post.post_status_id, postStatusId: action.post.post_status_id,
likeCount: action.post.likes_count, likeCount: action.post.likes_count,
@@ -59,6 +61,7 @@ const postReducer = (
description: action.post.description, description: action.post.description,
boardId: action.post.board_id, boardId: action.post.board_id,
postStatusId: action.post.post_status_id, postStatusId: action.post.post_status_id,
approvalStatus: action.post.approval_status,
}; };
default: default:

View File

@@ -7,8 +7,9 @@ import boardsReducer from './boardsReducer';
import postStatusesReducer from './postStatusesReducer'; import postStatusesReducer from './postStatusesReducer';
import usersReducer from './usersReducer'; import usersReducer from './usersReducer';
import currentPostReducer from './currentPostReducer'; import currentPostReducer from './currentPostReducer';
import siteSettingsReducer from './siteSettingsReducer';
import oAuthsReducer from './oAuthsReducer'; import oAuthsReducer from './oAuthsReducer';
import siteSettingsReducer from './siteSettingsReducer';
import moderationReducer from './moderationReducer';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
tenantSignUp: tenantSignUpReducer, tenantSignUp: tenantSignUpReducer,
@@ -18,8 +19,10 @@ const rootReducer = combineReducers({
postStatuses: postStatusesReducer, postStatuses: postStatusesReducer,
users: usersReducer, users: usersReducer,
currentPost: currentPostReducer, currentPost: currentPostReducer,
siteSettings: siteSettingsReducer,
oAuths: oAuthsReducer, oAuths: oAuthsReducer,
siteSettings: siteSettingsReducer,
moderation: moderationReducer,
}); });
export type State = ReturnType<typeof rootReducer> export type State = ReturnType<typeof rootReducer>

View File

@@ -61,13 +61,6 @@ import {
POSTSTATUS_UPDATE_FAILURE, POSTSTATUS_UPDATE_FAILURE,
} from '../actions/PostStatus/updatePostStatus'; } from '../actions/PostStatus/updatePostStatus';
import {
UserUpdateActionTypes,
USER_UPDATE_START,
USER_UPDATE_SUCCESS,
USER_UPDATE_FAILURE,
} from '../actions/User/updateUser';
import { import {
OAuthSubmitActionTypes, OAuthSubmitActionTypes,
OAUTH_SUBMIT_START, OAUTH_SUBMIT_START,
@@ -93,7 +86,6 @@ import siteSettingsGeneralReducer, { SiteSettingsGeneralState } from './SiteSett
import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer'; import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer';
import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer'; import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer';
import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer'; import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer';
import siteSettingsUsersReducer, { SiteSettingsUsersState } from './SiteSettings/usersReducer';
import siteSettingsAuthenticationReducer, { SiteSettingsAuthenticationState } from './SiteSettings/authenticationReducer'; import siteSettingsAuthenticationReducer, { SiteSettingsAuthenticationState } from './SiteSettings/authenticationReducer';
import siteSettingsAppearanceReducer, { SiteSettingsAppearanceState } from './SiteSettings/appearanceReducer'; import siteSettingsAppearanceReducer, { SiteSettingsAppearanceState } from './SiteSettings/appearanceReducer';
@@ -104,7 +96,6 @@ interface SiteSettingsState {
postStatuses: SiteSettingsPostStatusesState; postStatuses: SiteSettingsPostStatusesState;
roadmap: SiteSettingsRoadmapState; roadmap: SiteSettingsRoadmapState;
appearance: SiteSettingsAppearanceState; appearance: SiteSettingsAppearanceState;
users: SiteSettingsUsersState;
} }
const initialState: SiteSettingsState = { const initialState: SiteSettingsState = {
@@ -114,7 +105,6 @@ const initialState: SiteSettingsState = {
postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any), postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any),
roadmap: siteSettingsRoadmapReducer(undefined, {} as any), roadmap: siteSettingsRoadmapReducer(undefined, {} as any),
appearance: siteSettingsAppearanceReducer(undefined, {} as any), appearance: siteSettingsAppearanceReducer(undefined, {} as any),
users: siteSettingsUsersReducer(undefined, {} as any),
}; };
const siteSettingsReducer = ( const siteSettingsReducer = (
@@ -131,8 +121,7 @@ const siteSettingsReducer = (
PostStatusOrderUpdateActionTypes | PostStatusOrderUpdateActionTypes |
PostStatusDeleteActionTypes | PostStatusDeleteActionTypes |
PostStatusSubmitActionTypes | PostStatusSubmitActionTypes |
PostStatusUpdateActionTypes | PostStatusUpdateActionTypes
UserUpdateActionTypes
): SiteSettingsState => { ): SiteSettingsState => {
switch (action.type) { switch (action.type) {
case TENANT_UPDATE_START: case TENANT_UPDATE_START:
@@ -198,14 +187,6 @@ const siteSettingsReducer = (
roadmap: siteSettingsRoadmapReducer(state.roadmap, action), roadmap: siteSettingsRoadmapReducer(state.roadmap, action),
}; };
case USER_UPDATE_START:
case USER_UPDATE_SUCCESS:
case USER_UPDATE_FAILURE:
return {
...state,
users: siteSettingsUsersReducer(state.users, action),
};
default: default:
return state; return state;
} }

View File

@@ -3,7 +3,7 @@ class Post < ApplicationRecord
extend FriendlyId extend FriendlyId
belongs_to :board belongs_to :board
belongs_to :user belongs_to :user, optional: true
belongs_to :post_status, optional: true belongs_to :post_status, optional: true
has_many :likes, dependent: :destroy has_many :likes, dependent: :destroy
@@ -12,6 +12,12 @@ class Post < ApplicationRecord
has_many :comments, dependent: :destroy has_many :comments, dependent: :destroy
has_many :post_status_changes, dependent: :destroy has_many :post_status_changes, dependent: :destroy
enum approval_status: [
:approved,
:pending,
:rejected
]
validates :title, presence: true, length: { in: 4..128 } validates :title, presence: true, length: { in: 4..128 }
paginates_per Rails.application.posts_per_page paginates_per Rails.application.posts_per_page
@@ -43,5 +49,9 @@ class Post < ApplicationRecord
order(created_at: :desc) order(created_at: :desc)
end end
end end
def pending
where(approval_status: "pending")
end
end end
end end

View File

@@ -16,4 +16,10 @@ class TenantSetting < ApplicationRecord
:no_collapse, :no_collapse,
:always_collapse :always_collapse
] ]
enum feedback_approval_policy: [
:anonymous_require_approval,
:never_require_approval,
:always_require_approval,
]
end end

View File

@@ -5,7 +5,7 @@ class PostPolicy < ApplicationPolicy
def permitted_attributes_for_update def permitted_attributes_for_update
if user.moderator? if user.moderator?
[:title, :description, :board_id, :post_status_id] [:title, :description, :board_id, :post_status_id, :approval_status]
else else
[:title, :description] [:title, :description]
end end

View File

@@ -4,6 +4,8 @@ class TenantSettingPolicy < ApplicationPolicy
[ [
:brand_display, :brand_display,
:root_board_id, :root_board_id,
:allow_anonymous_feedback,
:feedback_approval_policy,
:show_vote_count, :show_vote_count,
:show_vote_button_in_board, :show_vote_button_in_board,
:show_powered_by, :show_powered_by,

View File

@@ -5,7 +5,9 @@
board: @board, board: @board,
isLoggedIn: user_signed_in?, isLoggedIn: user_signed_in?,
isPowerUser: user_signed_in? ? current_user.moderator? : false, isPowerUser: user_signed_in? ? current_user.moderator? : false,
currentUserFullName: user_signed_in? ? current_user.full_name_or_email : '',
tenantSetting: @tenant_setting, tenantSetting: @tenant_setting,
componentRenderedAt: Time.now.to_i,
authenticityToken: form_authenticity_token authenticityToken: form_authenticity_token
} }
) )

View File

@@ -44,6 +44,9 @@
<a class="profileToggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <a class="profileToggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<%= image_tag(current_user.gravatar_url, class: 'gravatar', alt: current_user.full_name, size: 24) %> <%= image_tag(current_user.gravatar_url, class: 'gravatar', alt: current_user.full_name, size: 24) %>
<span class="fullname"><%= current_user.full_name %></span> <span class="fullname"><%= current_user.full_name %></span>
<% if current_user.moderator? && Post.pending.length > 0 %>
<span class="notificationDot notificationDotTop"><%= Post.pending.length %></span>
<% end %>
</a> </a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown"> <div class="dropdown-menu" aria-labelledby="navbarDropdown">
<% if current_user.moderator? %> <% if current_user.moderator? %>
@@ -54,13 +57,16 @@
@header_full_urls ? get_url_for(method(:site_settings_general_url)) : site_settings_general_path, @header_full_urls ? get_url_for(method(:site_settings_general_url)) : site_settings_general_path,
class: 'dropdown-item siteSettingsDropdown' class: 'dropdown-item siteSettingsDropdown'
%> %>
<% else %>
<%=
link_to t('header.menu.site_settings'),
@header_full_urls ? get_url_for(method(:site_settings_users_url)) : site_settings_users_path,
class: 'dropdown-item siteSettingsDropdown'
%>
<% end %> <% end %>
<%= link_to @header_full_urls ? get_url_for(method(:moderation_feedback_url)) : moderation_feedback_path, class: 'dropdown-item moderationDropdown' do %>
<%= t('header.menu.moderation') %>
<% if Post.pending.length > 0 %>
&nbsp;
<span class="notificationDot"><%= Post.pending.length %></span>
<% end %>
<% end %>
<% if current_user.owner? and Rails.application.multi_tenancy? %> <% if current_user.owner? and Rails.application.multi_tenancy? %>
<%= link_to @header_full_urls ? get_url_for(method(:request_billing_page_url)) : request_billing_page_path, class: 'dropdown-item', data: {turbolinks: false} do %> <%= link_to @header_full_urls ? get_url_for(method(:request_billing_page_url)) : request_billing_page_path, class: 'dropdown-item', data: {turbolinks: false} do %>
<%= t('billing.title') %> <%= t('billing.title') %>

View File

@@ -0,0 +1,10 @@
<div class="sidebar">
<div class="sidebarBox">
<span class="boxTitleText"><%= t('header.menu.moderation') %></span>
<div class="verticalNavigation" role="tablist" aria-orientation="vertical">
<%= render 'shared/sidebar_menu_link', label: t('moderation.menu.feedback'), path: moderation_feedback_path %>
<%= render 'shared/sidebar_menu_link', label: t('moderation.menu.users'), path: moderation_users_path %>
</div>
</div>
</div>

View File

@@ -0,0 +1,17 @@
<div class="twoColumnsContainer">
<%= render 'menu' %>
<div>
<%=
react_component(
'Moderation/Feedback',
{
currentUserRole: current_user.role,
changeFeedbackModerationSettingsUrl: site_settings_general_path(anchor: 'moderation'),
tenantSettingAllowAnonymousFeedback: @tenant.tenant_setting.allow_anonymous_feedback,
tenantSettingFeedbackApprovalPolicy: @tenant.tenant_setting.feedback_approval_policy,
authenticityToken: form_authenticity_token
}
)
%>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<div> <div>
<%= <%=
react_component( react_component(
'SiteSettings/Users', 'Moderation/Users',
{ {
currentUserEmail: current_user.email, currentUserEmail: current_user.email,
currentUserRole: current_user.role, currentUserRole: current_user.role,

View File

@@ -3,16 +3,12 @@
<span class="boxTitleText"><%= t('site_settings.menu.title') %></span> <span class="boxTitleText"><%= t('site_settings.menu.title') %></span>
<div class="verticalNavigation" role="tablist" aria-orientation="vertical"> <div class="verticalNavigation" role="tablist" aria-orientation="vertical">
<% if current_user.admin? %> <%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.general'), path: site_settings_general_path %>
<%= render 'menu_link', label: t('site_settings.menu.general'), path: site_settings_general_path %> <%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.authentication'), path: site_settings_authentication_path %>
<%= render 'menu_link', label: t('site_settings.menu.authentication'), path: site_settings_authentication_path %> <%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.boards'), path: site_settings_boards_path %>
<%= render 'menu_link', label: t('site_settings.menu.boards'), path: site_settings_boards_path %> <%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.post_statuses'), path: site_settings_post_statuses_path %>
<%= render 'menu_link', label: t('site_settings.menu.post_statuses'), path: site_settings_post_statuses_path %> <%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %>
<%= render 'menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %> <%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.appearance'), path: site_settings_appearance_path %>
<%= render 'menu_link', label: t('site_settings.menu.appearance'), path: site_settings_appearance_path %>
<% end %>
<%= render 'menu_link', label: t('site_settings.menu.users'), path: site_settings_users_path %>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,8 @@
showPoweredBy: @tenant_setting.show_powered_by, showPoweredBy: @tenant_setting.show_powered_by,
rootBoardId: @tenant_setting.root_board_id.to_s, rootBoardId: @tenant_setting.root_board_id.to_s,
customDomain: @tenant.custom_domain, customDomain: @tenant.custom_domain,
allowAnonymousFeedback: @tenant_setting.allow_anonymous_feedback,
feedbackApprovalPolicy: @tenant_setting.feedback_approval_policy,
showRoadmapInHeader: @tenant_setting.show_roadmap_in_header, showRoadmapInHeader: @tenant_setting.show_roadmap_in_header,
collapseBoardsInHeader: @tenant_setting.collapse_boards_in_header, collapseBoardsInHeader: @tenant_setting.collapse_boards_in_header,
locale: @tenant.locale locale: @tenant.locale

View File

@@ -57,6 +57,29 @@ class Rack::Attack
end end
end end
# Throttle POST requests to /posts by IP address using anti-spam measures
throttle('posts/ip', limit: 1, period: 1.minute) do |req|
if req.path == '/posts' && req.post?
ip = req.get_header("action_dispatch.remote_ip")
real_req = ActionDispatch::Request.new(req.env) # Needed to parse JSON body
# Check for honeypot field submission
honeypot_filled = real_req.params['post']['dnf1'] != "" || real_req.params['post']['dnf2'] != ""
# Check for time of form render
too_fast_submit = Time.now.to_i - real_req.params[:post][:form_rendered_at] < 3
if honeypot_filled || too_fast_submit
Rack::Attack.cache.store.write("post-submit-antispam-#{ip}", true, expires_in: 1.minute)
end
# Block if this IP was previously flagged
if Rack::Attack.cache.store.read("post-submit-antispam-#{ip}")
ip
end
end
end
### Custom Throttle Response ### ### Custom Throttle Response ###
# By default, Rack::Attack returns an HTTP 429 for throttled responses, # By default, Rack::Attack returns an HTTP 429 for throttled responses,

View File

@@ -109,6 +109,10 @@ en:
post: post:
title: 'Title' title: 'Title'
description: 'Description' description: 'Description'
approval_status: 'Approval status'
approval_status_approved: 'Approved'
approval_status_pending: 'Pending approval'
approval_status_rejected: 'Rejected'
board_id: 'Post board' board_id: 'Post board'
user_id: 'Post author' user_id: 'Post author'
post_status_id: 'Post status' post_status_id: 'Post status'
@@ -120,6 +124,8 @@ en:
custom_domain: 'Custom domain' custom_domain: 'Custom domain'
tenant_setting: tenant_setting:
brand_display: 'Display' brand_display: 'Display'
allow_anonymous_feedback: 'Allow anonymous feedback'
feedback_approval_policy: 'Feedback approval policy'
show_vote_count: 'Show vote count to users' show_vote_count: 'Show vote count to users'
show_vote_button_in_board: 'Show vote buttons in board page' show_vote_button_in_board: 'Show vote buttons in board page'
show_powered_by: 'Show "Powered by Astuto"' show_powered_by: 'Show "Powered by Astuto"'

View File

@@ -1,5 +1,7 @@
en: en:
common: common:
words:
or: 'or'
errors: errors:
unknown: 'An unknown error occurred, please try again' unknown: 'An unknown error occurred, please try again'
validations: validations:
@@ -65,6 +67,8 @@ en:
back: 'Back' back: 'Back'
test: 'Test' test: 'Test'
clear: 'Clear' clear: 'Clear'
approve: 'Approve'
reject: 'Reject'
datetime: datetime:
now: 'just now' now: 'just now'
minutes: minutes:
@@ -80,6 +84,7 @@ en:
menu: menu:
administration_header: 'Administration' administration_header: 'Administration'
site_settings: 'Site settings' site_settings: 'Site settings'
moderation: 'Moderation'
profile_header: 'Profile' profile_header: 'Profile'
profile_settings: 'Profile settings' profile_settings: 'Profile settings'
help_header: 'Help' help_header: 'Help'
@@ -97,12 +102,16 @@ en:
board: board:
new_post: new_post:
submit_button: 'Submit feedback' submit_button: 'Submit feedback'
submit_anonymous_button: 'Submit anonymously'
cancel_button: 'Cancel' cancel_button: 'Cancel'
login_button: 'Log in / Sign up' login_button: 'Log in / Sign up'
title: 'Title' title: 'Title'
description: 'Description (optional)' description: 'Description (optional)'
no_title: 'Title field is mandatory' no_title: 'Title field is mandatory'
anonymous_submission_help: 'You are posting anonymously'
non_anonymous_submission_help: 'You are posting as %{name}'
submit_success: 'Feedback published! You will be redirected soon...' submit_success: 'Feedback published! You will be redirected soon...'
submit_pending: 'Your feedback has been submitted and is now pending moderator approval!'
search_box: search_box:
title: 'Search' title: 'Search'
filter_box: filter_box:
@@ -122,6 +131,7 @@ en:
post: post:
edit_button: 'Edit' edit_button: 'Edit'
published_by: 'Published by' published_by: 'Published by'
published_anonymously: 'Published anonymously'
post_status_select: post_status_select:
no_post_status: 'None' no_post_status: 'None'
updates_box: updates_box:
@@ -153,7 +163,6 @@ en:
boards: 'Boards' boards: 'Boards'
post_statuses: 'Statuses' post_statuses: 'Statuses'
roadmap: 'Roadmap' roadmap: 'Roadmap'
users: 'Users'
authentication: 'Authentication' authentication: 'Authentication'
appearance: 'Appearance' appearance: 'Appearance'
info_box: info_box:
@@ -166,6 +175,12 @@ en:
brand_setting_name: 'Name only' brand_setting_name: 'Name only'
brand_setting_logo: 'Logo only' brand_setting_logo: 'Logo only'
brand_setting_none: 'None' brand_setting_none: 'None'
subtitle_moderation: 'Moderation'
allow_anonymous_feedback_help: 'Unregistered users will be able to submit feedback.'
feedback_approval_policy_anonymous_require_approval: 'Require approval for anonymous feedback only'
feedback_approval_policy_never_require_approval: 'Never require approval'
feedback_approval_policy_always_require_approval: 'Always require approval'
feedback_approval_policy_help: 'If you require approval, submitted feedback will remain hidden from the public until a moderator or administrator approves it. Feedback submitted by moderators and administrators is always approved automatically.'
subtitle_header: 'Header' subtitle_header: 'Header'
collapse_boards_in_header_no_collapse: 'Never' collapse_boards_in_header_no_collapse: 'Never'
collapse_boards_in_header_always_collapse: 'Always' collapse_boards_in_header_always_collapse: 'Always'
@@ -195,21 +210,6 @@ en:
appearance: appearance:
title: 'Appearance' title: 'Appearance'
learn_more: 'Learn how to customize appearance' learn_more: 'Learn how to customize appearance'
users:
title: 'Users'
block: 'Block'
unblock: 'Unblock'
block_confirmation: "%{name} won't be able to log in until it is unblocked. Are you sure?"
unblock_confirmation: "%{name} will be able to log in and submit feedback. Are you sure?"
role_to_moderator_confirmation: "%{name} will be able to manage posts and users. Proceed only if you trust this person. Are you sure?"
role_to_admin_confirmation: "%{name} will be able to manage boards, posts, statuses, users and more. Proceed only if you trust this person. Are you sure?"
role_user: 'User'
role_moderator: 'Moderator'
role_admin: 'Administrator'
role_owner: 'Owner'
status_active: 'Active'
status_blocked: 'Blocked'
status_deleted: 'Deleted'
authentication: authentication:
title: 'Authentication' title: 'Authentication'
learn_more: 'Learn how to configure custom OAuth providers' learn_more: 'Learn how to configure custom OAuth providers'
@@ -230,3 +230,26 @@ en:
subtitle_oauth_config: 'OAuth configuration' subtitle_oauth_config: 'OAuth configuration'
subtitle_user_profile_config: 'User profile configuration' subtitle_user_profile_config: 'User profile configuration'
client_secret_help: 'hidden for security purposes' client_secret_help: 'hidden for security purposes'
moderation:
menu:
feedback: 'Feedback'
users: 'Users'
feedback:
anonymous_feedback_allowed: 'Anonymous feedback allowed'
anonymous_feedback_not_allowed: 'Anonymous feedback not allowed'
change_feedback_moderation_settings: 'Change feedback moderation settings'
users:
title: 'Users'
block: 'Block'
unblock: 'Unblock'
block_confirmation: "%{name} won't be able to log in until it is unblocked. Are you sure?"
unblock_confirmation: "%{name} will be able to log in and submit feedback. Are you sure?"
role_to_moderator_confirmation: "%{name} will be able to manage posts and users. Proceed only if you trust this person. Are you sure?"
role_to_admin_confirmation: "%{name} will be able to manage boards, posts, statuses, users and more. Proceed only if you trust this person. Are you sure?"
role_user: 'User'
role_moderator: 'Moderator'
role_admin: 'Administrator'
role_owner: 'Owner'
status_active: 'Active'
status_blocked: 'Blocked'
status_deleted: 'Deleted'

View File

@@ -55,6 +55,8 @@ Rails.application.routes.draw do
resources :likes, only: [:index] resources :likes, only: [:index]
resources :comments, only: [:index, :create, :update, :destroy] resources :comments, only: [:index, :create, :update, :destroy]
resources :post_status_changes, only: [:index] resources :post_status_changes, only: [:index]
get '/moderation', on: :collection, to: 'posts#moderation'
end end
resources :boards, only: [:index, :create, :update, :destroy, :show] do resources :boards, only: [:index, :create, :update, :destroy, :show] do
@@ -72,6 +74,10 @@ Rails.application.routes.draw do
get 'post_statuses' get 'post_statuses'
get 'roadmap' get 'roadmap'
get 'appearance' get 'appearance'
end
namespace :moderation do
get 'feedback'
get 'users' get 'users'
end end
end end

View File

@@ -0,0 +1,6 @@
class AddAnonymousFeedbackToTenantSettings < ActiveRecord::Migration[6.1]
def change
add_column :tenant_settings, :allow_anonymous_feedback, :boolean, default: true, null: false
add_column :tenant_settings, :feedback_approval_policy, :integer, default: 0, null: false
end
end

View File

@@ -0,0 +1,5 @@
class AddApprovalStatusToPost < ActiveRecord::Migration[6.1]
def change
add_column :posts, :approval_status, :integer, default: 0, null: false
end
end

View File

@@ -0,0 +1,5 @@
class RemoveNotNullUserIdFromPosts < ActiveRecord::Migration[6.1]
def change
change_column :posts, :user_id, :bigint, null: true
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2024_05_21_124018) do ActiveRecord::Schema.define(version: 2024_07_08_191938) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -117,12 +117,13 @@ ActiveRecord::Schema.define(version: 2024_05_21_124018) do
t.string "title", null: false t.string "title", null: false
t.text "description" t.text "description"
t.bigint "board_id", null: false t.bigint "board_id", null: false
t.bigint "user_id", null: false t.bigint "user_id"
t.bigint "post_status_id" t.bigint "post_status_id"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.bigint "tenant_id", null: false t.bigint "tenant_id", null: false
t.string "slug" t.string "slug"
t.integer "approval_status", default: 0, null: false
t.index ["board_id"], name: "index_posts_on_board_id" t.index ["board_id"], name: "index_posts_on_board_id"
t.index ["post_status_id"], name: "index_posts_on_post_status_id" t.index ["post_status_id"], name: "index_posts_on_post_status_id"
t.index ["slug", "tenant_id"], name: "index_posts_on_slug_and_tenant_id", unique: true t.index ["slug", "tenant_id"], name: "index_posts_on_slug_and_tenant_id", unique: true
@@ -165,6 +166,8 @@ ActiveRecord::Schema.define(version: 2024_05_21_124018) do
t.integer "collapse_boards_in_header", default: 0, null: false t.integer "collapse_boards_in_header", default: 0, null: false
t.text "custom_css" t.text "custom_css"
t.boolean "show_powered_by", default: true, null: false t.boolean "show_powered_by", default: true, null: false
t.boolean "allow_anonymous_feedback", default: true, null: false
t.integer "feedback_approval_policy", default: 0, null: false
t.index ["tenant_id"], name: "index_tenant_settings_on_tenant_id" t.index ["tenant_id"], name: "index_tenant_settings_on_tenant_id"
end end

View File

@@ -41,13 +41,13 @@ RSpec.describe Post, type: :model do
expect(no_status_post).to be_valid expect(no_status_post).to be_valid
end end
it 'has a reference to a user than cannot be nil' do it 'has a reference to a user that can be nil (for anonymous feedback)' do
no_user_post = FactoryBot.build(:post, user_id: nil) no_user_post = FactoryBot.build(:post, user_id: nil)
expect(no_user_post).to be_invalid expect(no_user_post).to be_valid
end end
it 'has a reference to a board than cannot be nil' do it 'has a reference to a board that cannot be nil' do
no_board_post = FactoryBot.build(:post, board_id: nil) no_board_post = FactoryBot.build(:post, board_id: nil)
expect(no_board_post).to be_invalid expect(no_board_post).to be_invalid
@@ -77,4 +77,14 @@ RSpec.describe Post, type: :model do
expect { Post.search_by_name_or_description(nil) }.not_to raise_error expect { Post.search_by_name_or_description(nil) }.not_to raise_error
expect { Post.search_by_name_or_description('dangerous symbols: " \' %') }.not_to raise_error expect { Post.search_by_name_or_description('dangerous symbols: " \' %') }.not_to raise_error
end end
it 'has an approval status that can be approved, pending or rejected' do
expect(post.approval_status).to eq('approved')
post.approval_status = 'pending'
expect(post).to be_valid
post.approval_status = 'rejected'
expect(post).to be_valid
end
end end

View File

@@ -46,4 +46,18 @@ RSpec.describe TenantSetting, type: :model do
tenant_setting.collapse_boards_in_header = 'always_collapse' tenant_setting.collapse_boards_in_header = 'always_collapse'
expect(tenant_setting).to be_valid expect(tenant_setting).to be_valid
end end
it 'has a setting to allow anonymous feedback' do
expect(tenant_setting.allow_anonymous_feedback).to be_truthy
end
it 'has a setting to require approval for feedback' do
expect(tenant_setting.feedback_approval_policy).to eq('anonymous_require_approval')
tenant_setting.feedback_approval_policy = 'never_require_approval'
expect(tenant_setting).to be_valid
tenant_setting.feedback_approval_policy = 'always_require_approval'
expect(tenant_setting).to be_valid
end
end end

View File

@@ -31,8 +31,8 @@ RSpec.describe PostPolicy do
it { should permit(:update) } it { should permit(:update) }
it { should permit(:destroy) } it { should permit(:destroy) }
it 'permits "title", "description", "board_id" and "post_status_id" attributes' do it 'permits "title", "description", "board_id", "post_status_id" and "approval_status" attributes' do
permitted_attributes = [:title, :description, :board_id, :post_status_id] permitted_attributes = [:title, :description, :board_id, :post_status_id, :approval_status]
expect(subject.permitted_attributes_for_update).to eq(permitted_attributes) expect(subject.permitted_attributes_for_update).to eq(permitted_attributes)
end end
end end

View File

@@ -0,0 +1,17 @@
require 'rails_helper'
RSpec.describe 'moderation routing', :aggregate_failures, type: :routing do
let (:base_url) { '/moderation' }
it 'routes feedback' do
expect(get: base_url + '/feedback').to route_to(
controller: 'moderation', action: 'feedback'
)
end
it 'routes users' do
expect(get: base_url + '/users').to route_to(
controller: 'moderation', action: 'users'
)
end
end

View File

@@ -26,10 +26,4 @@ RSpec.describe 'site settings routing', :aggregate_failures, type: :routing do
controller: 'site_settings', action: 'roadmap' controller: 'site_settings', action: 'roadmap'
) )
end end
it 'routes users' do
expect(get: base_url + '/users').to route_to(
controller: 'site_settings', action: 'users'
)
end
end end

View File

@@ -60,16 +60,6 @@ feature 'board', type: :system, js: true do
end end
end end
it 'renders a log in button if not logged in' do
visit board_path(board)
within sidebar do
expect(page).to have_content(/Log in \/ Sign up/i)
click_link 'Log in / Sign up'
expect(page).to have_current_path(new_user_session_path)
end
end
it 'renders a submit feedback button that shows a form if logged in' do it 'renders a submit feedback button that shows a form if logged in' do
user.confirm user.confirm
sign_in user sign_in user
@@ -82,8 +72,6 @@ feature 'board', type: :system, js: true do
click_button 'Submit feedback' # open submit form click_button 'Submit feedback' # open submit form
expect(page).to have_css(new_post_form) expect(page).to have_css(new_post_form)
expect(page).to have_content(/Title/i)
expect(page).to have_content(/Description/i)
end end
end end
@@ -103,6 +91,9 @@ feature 'board', type: :system, js: true do
fill_in 'Title', with: post_title fill_in 'Title', with: post_title
fill_in 'Description (optional)', with: post_description fill_in 'Description (optional)', with: post_description
sleep 5 # needed to avoid time check anti-spam measure
click_button 'Submit feedback' # submit click_button 'Submit feedback' # submit
end end

View File

@@ -1,6 +1,6 @@
require 'rails_helper' require 'rails_helper'
feature 'site settings: users', type: :system, js: true do feature 'moderation: users', type: :system, js: true do
let(:admin) { FactoryBot.create(:admin) } let(:admin) { FactoryBot.create(:admin) }
let(:user) { FactoryBot.create(:user) } let(:user) { FactoryBot.create(:user) }
@@ -14,7 +14,7 @@ feature 'site settings: users', type: :system, js: true do
user user
visit site_settings_users_path visit moderation_users_path
end end
it 'lets view existing users' do it 'lets view existing users' do