mirror of
https://github.com/astuto/astuto.git
synced 2025-12-16 11:47:56 +01:00
Add anonymous feedback (#380)
This commit is contained in:
committed by
GitHub
parent
7a37dae22d
commit
a49b5695f5
@@ -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';
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.generalSiteSettingsContainer {
|
||||||
|
.settingsGroup {
|
||||||
|
@extend .mt-4;
|
||||||
|
|
||||||
|
scroll-margin-top: 96px;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/controllers/moderation_controller.rb
Normal file
18
app/controllers/moderation_controller.rb
Normal 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
|
||||||
@@ -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,22 +25,40 @@ 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])
|
||||||
|
|
||||||
# apply post status filter if present
|
# apply post status filter if present
|
||||||
posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present?
|
posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present?
|
||||||
|
|
||||||
render json: posts
|
render json: posts
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ module ApplicationHelper
|
|||||||
def authenticate_moderator
|
def authenticate_moderator
|
||||||
return if check_user_signed_in == false
|
return if check_user_signed_in == false
|
||||||
|
|
||||||
unless current_user.moderator?
|
unless current_user.moderator?
|
||||||
flash[:alert] = t('errors.not_enough_privileges')
|
flash[:alert] = t('errors.not_enough_privileges')
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
this.setState({
|
if (json.approval_status === POST_APPROVAL_STATUS_APPROVED) {
|
||||||
success: I18n.t('board.new_post.submit_success'),
|
this.setState({
|
||||||
|
success: I18n.t('board.new_post.submit_success'),
|
||||||
|
});
|
||||||
|
|
||||||
title: '',
|
setTimeout(() => (
|
||||||
description: '',
|
window.location.href = `/posts/${json.slug || json.id}`
|
||||||
});
|
), 1000);
|
||||||
|
} else {
|
||||||
setTimeout(() => (
|
this.setState({
|
||||||
window.location.href = `/posts/${json.slug || json.id}`
|
success: I18n.t('board.new_post.submit_pending'),
|
||||||
), 1000);
|
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,23 +208,47 @@ class NewPost extends React.Component<Props, State> {
|
|||||||
{board.description}
|
{board.description}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
|
||||||
|
if (showForm) {
|
||||||
|
this.toggleForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
this.toggleForm();
|
||||||
|
this.setState({ isSubmissionAnonymous: false });
|
||||||
|
} else {
|
||||||
|
window.location.href = '/users/sign_in';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="submitBtn"
|
||||||
|
outline={showForm}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
showForm ?
|
||||||
|
I18n.t('board.new_post.cancel_button')
|
||||||
|
:
|
||||||
|
I18n.t('board.new_post.submit_button')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{
|
{
|
||||||
isLoggedIn ?
|
(isAnonymousFeedbackAllowed && !showForm) &&
|
||||||
<Button
|
<div className="anonymousFeedbackLink">
|
||||||
onClick={this.toggleForm}
|
{I18n.t('common.words.or')}
|
||||||
className="submitBtn"
|
|
||||||
outline={showForm}>
|
<a
|
||||||
{
|
onClick={() => {
|
||||||
showForm ?
|
this.toggleForm();
|
||||||
I18n.t('board.new_post.cancel_button')
|
this.setState({ isSubmissionAnonymous: true });
|
||||||
:
|
}}
|
||||||
I18n.t('board.new_post.submit_button')
|
className="link"
|
||||||
}
|
>
|
||||||
</Button>
|
{I18n.t('board.new_post.submit_anonymous_button').toLowerCase()}
|
||||||
:
|
</a>
|
||||||
<a href="/users/sign_in" className="btn btnPrimary">
|
</div>
|
||||||
{I18n.t('board.new_post.login_button')}
|
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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')}
|
||||||
|
|
||||||
|
({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;
|
||||||
44
app/javascript/components/Moderation/Feedback/index.tsx
Normal file
44
app/javascript/components/Moderation/Feedback/index.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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})}
|
||||||
(
|
(
|
||||||
{numberOfActiveUsers} {I18n.t('site_settings.users.status_active')},
|
{numberOfActiveUsers} {I18n.t('moderation.users.status_active')},
|
||||||
{numberOfBlockedUsers} {I18n.t('site_settings.users.status_blocked')},
|
{numberOfBlockedUsers} {I18n.t('moderation.users.status_blocked')},
|
||||||
{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;
|
||||||
@@ -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;
|
||||||
@@ -29,14 +29,23 @@ const PostFooter = ({
|
|||||||
}: Props) => (
|
}: Props) => (
|
||||||
<div className="postFooter">
|
<div className="postFooter">
|
||||||
<div className="postAuthor">
|
<div className="postAuthor">
|
||||||
<span>{I18n.t('post.published_by').toLowerCase()} </span>
|
{
|
||||||
<Gravatar email={authorEmail} size={24} className="postAuthorAvatar" />
|
authorEmail ?
|
||||||
{authorFullName}
|
<>
|
||||||
|
<span>{I18n.t('post.published_by').toLowerCase()} </span>
|
||||||
|
<Gravatar email={authorEmail} size={24} className="postAuthorAvatar" />
|
||||||
|
<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')}
|
||||||
|
|||||||
@@ -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']}
|
||||||
|
|||||||
@@ -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,64 +254,98 @@ const GeneralSiteSettingsP = ({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<br />
|
<div id="moderation" className="settingsGroup">
|
||||||
<h4>{ I18n.t('site_settings.general.subtitle_header') }</h4>
|
<h4>{ I18n.t('site_settings.general.subtitle_moderation') }</h4>
|
||||||
|
|
||||||
<div className="formGroup">
|
<div className="formGroup">
|
||||||
<div className="checkboxSwitch">
|
<div className="checkboxSwitch">
|
||||||
<input {...register('showRoadmapInHeader')} type="checkbox" id="show_roadmap_in_header" />
|
<input {...register('allowAnonymousFeedback')} type="checkbox" id="allow_anonymous_feedback" />
|
||||||
<label htmlFor="show_roadmap_in_header">{ getLabel('tenant_setting', 'show_roadmap_in_header') }</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<br />
|
<div id="header" className="settingsGroup">
|
||||||
<div className="formGroup">
|
<h4>{ I18n.t('site_settings.general.subtitle_header') }</h4>
|
||||||
<label htmlFor="collapseBoardsInHeader">{ getLabel('tenant_setting', 'collapse_boards_in_header') }</label>
|
|
||||||
<select
|
|
||||||
{...register('collapseBoardsInHeader')}
|
|
||||||
id="collapseBoardsInHeader"
|
|
||||||
className="selectPicker"
|
|
||||||
>
|
|
||||||
<option value={TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_NO_COLLAPSE}>
|
|
||||||
{ I18n.t('site_settings.general.collapse_boards_in_header_no_collapse') }
|
|
||||||
</option>
|
|
||||||
<option value={TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_ALWAYS_COLLAPSE}>
|
|
||||||
{ I18n.t('site_settings.general.collapse_boards_in_header_always_collapse') }
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<br />
|
<div className="formGroup">
|
||||||
<h4>{ I18n.t('site_settings.general.subtitle_visibility') }</h4>
|
<div className="checkboxSwitch">
|
||||||
|
<input {...register('showRoadmapInHeader')} type="checkbox" id="show_roadmap_in_header" />
|
||||||
|
<label htmlFor="show_roadmap_in_header">{ getLabel('tenant_setting', 'show_roadmap_in_header') }</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="formGroup">
|
<div className="formGroup">
|
||||||
<div className="checkboxSwitch">
|
<label htmlFor="collapseBoardsInHeader">{ getLabel('tenant_setting', 'collapse_boards_in_header') }</label>
|
||||||
<input {...register('showVoteCount')} type="checkbox" id="show_vote_count_checkbox" />
|
<select
|
||||||
<label htmlFor="show_vote_count_checkbox">{ getLabel('tenant_setting', 'show_vote_count') }</label>
|
{...register('collapseBoardsInHeader')}
|
||||||
<SmallMutedText>
|
id="collapseBoardsInHeader"
|
||||||
{ I18n.t('site_settings.general.show_vote_count_help') }
|
className="selectPicker"
|
||||||
</SmallMutedText>
|
>
|
||||||
|
<option value={TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_NO_COLLAPSE}>
|
||||||
|
{ I18n.t('site_settings.general.collapse_boards_in_header_no_collapse') }
|
||||||
|
</option>
|
||||||
|
<option value={TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_ALWAYS_COLLAPSE}>
|
||||||
|
{ I18n.t('site_settings.general.collapse_boards_in_header_always_collapse') }
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br />
|
<div id="visibility" className="settingsGroup">
|
||||||
|
<br />
|
||||||
|
<h4>{ I18n.t('site_settings.general.subtitle_visibility') }</h4>
|
||||||
|
|
||||||
<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('showVoteCount')} type="checkbox" id="show_vote_count_checkbox" />
|
||||||
<label htmlFor="show_vote_button_in_board_checkbox">{ getLabel('tenant_setting', 'show_vote_button_in_board') }</label>
|
<label htmlFor="show_vote_count_checkbox">{ getLabel('tenant_setting', 'show_vote_count') }</label>
|
||||||
<SmallMutedText>
|
<SmallMutedText>
|
||||||
{ I18n.t('site_settings.general.show_vote_button_in_board_help') }
|
{ I18n.t('site_settings.general.show_vote_count_help') }
|
||||||
</SmallMutedText>
|
</SmallMutedText>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<br />
|
<div className="formGroup">
|
||||||
|
<div className="checkboxSwitch">
|
||||||
|
<input {...register('showVoteButtonInBoard')} type="checkbox" id="show_vote_button_in_board_checkbox" />
|
||||||
|
<label htmlFor="show_vote_button_in_board_checkbox">{ getLabel('tenant_setting', 'show_vote_button_in_board') }</label>
|
||||||
|
<SmallMutedText>
|
||||||
|
{ I18n.t('site_settings.general.show_vote_button_in_board_help') }
|
||||||
|
</SmallMutedText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
23
app/javascript/components/common/Badge.tsx
Normal file
23
app/javascript/components/common/Badge.tsx
Normal 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;
|
||||||
@@ -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 />;
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/javascript/containers/FeedbackModeration.tsx
Normal file
34
app/javascript/containers/FeedbackModeration.tsx
Normal 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);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
67
app/javascript/reducers/Moderation/feedbackReducer.ts
Normal file
67
app/javascript/reducers/Moderation/feedbackReducer.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
55
app/javascript/reducers/moderationReducer.ts
Normal file
55
app/javascript/reducers/moderationReducer.ts
Normal 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;
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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? %>
|
||||||
@@ -51,16 +54,19 @@
|
|||||||
<% if current_user.admin? or current_user.owner? %>
|
<% if current_user.admin? or current_user.owner? %>
|
||||||
<%=
|
<%=
|
||||||
link_to t('header.menu.site_settings'),
|
link_to t('header.menu.site_settings'),
|
||||||
@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'
|
|
||||||
%>
|
|
||||||
<% 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'
|
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 %>
|
||||||
|
|
||||||
|
<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') %>
|
||||||
|
|||||||
10
app/views/moderation/_menu.html.erb
Normal file
10
app/views/moderation/_menu.html.erb
Normal 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>
|
||||||
17
app/views/moderation/feedback.html.erb
Normal file
17
app/views/moderation/feedback.html.erb
Normal 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>
|
||||||
@@ -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,
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"'
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
5
db/migrate/20240708191529_add_approval_status_to_post.rb
Normal file
5
db/migrate/20240708191529_add_approval_status_to_post.rb
Normal 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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class RemoveNotNullUserIdFromPosts < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
change_column :posts, :user_id, :bigint, null: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
17
spec/routing/moderation_routing_spec.rb
Normal file
17
spec/routing/moderation_routing_spec.rb
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user