Add anonymous feedback (#380)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,9 +64,9 @@ class UserEditable extends React.Component<Props, State> {
const confirmationMessage =
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);
@@ -101,7 +101,7 @@ class UserEditable extends React.Component<Props, State> {
<div className="userRoleStatus">
<span>
<MutedText>{ I18n.t(`site_settings.users.role_${user.role}`) }</MutedText>
<MutedText>{ I18n.t(`moderation.users.role_${user.role}`) }</MutedText>
</span>
{
@@ -109,7 +109,7 @@ class UserEditable extends React.Component<Props, State> {
<>
<Separator />
<span className={`userStatus userStatus${user.status}`}>
{ I18n.t(`site_settings.users.status_${user.status}`) }
{ I18n.t(`moderation.users.status_${user.status}`) }
</span>
</>
:
@@ -137,9 +137,9 @@ class UserEditable extends React.Component<Props, State> {
>
{
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>
</div>

View File

@@ -30,9 +30,9 @@ class UserForm extends React.Component<Props, State> {
if (selectedRole !== currentRole) {
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')
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);
@@ -60,13 +60,13 @@ class UserForm extends React.Component<Props, State> {
>
<optgroup label={getLabel('user', 'role')}>
<option value={USER_ROLE_USER}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_USER}`) }
{ I18n.t(`moderation.users.role_${USER_ROLE_USER}`) }
</option>
<option value={USER_ROLE_MODERATOR}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_MODERATOR}`) }
{ I18n.t(`moderation.users.role_${USER_ROLE_MODERATOR}`) }
</option>
<option value={USER_ROLE_ADMIN}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_ADMIN}`) }
{ I18n.t(`moderation.users.role_${USER_ROLE_ADMIN}`) }
</option>
</optgroup>
</select>

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import ReactMarkdown from 'react-markdown';
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 IBoard from '../../interfaces/IBoard';
import ITenantSetting from '../../interfaces/ITenantSetting';
@@ -28,6 +28,7 @@ import { fromRailsStringToJavascriptDate } from '../../helpers/datetime';
import HttpStatus from '../../constants/http_status';
import ActionLink from '../common/ActionLink';
import { EditIcon } from '../common/Icons';
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_WARNING } from '../common/Badge';
interface Props {
postId: number;
@@ -249,6 +250,15 @@ class PostP extends React.Component<Props> {
</ActionLink>
}
</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
className="postDescription"

View File

@@ -25,13 +25,15 @@ export interface ISiteSettingsGeneralForm {
siteLogo: string;
brandDisplaySetting: string;
locale: string;
rootBoardId?: string;
customDomain?: string;
allowAnonymousFeedback: boolean;
feedbackApprovalPolicy: string;
showRoadmapInHeader: boolean;
collapseBoardsInHeader: string;
showVoteCount: boolean;
showVoteButtonInBoard: boolean;
showPoweredBy: boolean;
rootBoardId?: string;
customDomain?: string;
showRoadmapInHeader: boolean;
collapseBoardsInHeader: string;
}
interface Props {
@@ -50,6 +52,8 @@ interface Props {
locale: string,
rootBoardId: number,
customDomain: string,
allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: string,
showRoadmapInHeader: boolean,
collapseBoardsInHeader: string,
showVoteCount: boolean,
@@ -80,13 +84,15 @@ const GeneralSiteSettingsP = ({
siteLogo: originForm.siteLogo,
brandDisplaySetting: originForm.brandDisplaySetting,
locale: originForm.locale,
rootBoardId: originForm.rootBoardId,
customDomain: originForm.customDomain,
allowAnonymousFeedback: originForm.allowAnonymousFeedback,
feedbackApprovalPolicy: originForm.feedbackApprovalPolicy,
showRoadmapInHeader: originForm.showRoadmapInHeader,
collapseBoardsInHeader: originForm.collapseBoardsInHeader,
showVoteCount: originForm.showVoteCount,
showVoteButtonInBoard: originForm.showVoteButtonInBoard,
showPoweredBy: originForm.showPoweredBy,
rootBoardId: originForm.rootBoardId,
customDomain: originForm.customDomain,
showRoadmapInHeader: originForm.showRoadmapInHeader,
collapseBoardsInHeader: originForm.collapseBoardsInHeader,
},
});
@@ -98,6 +104,8 @@ const GeneralSiteSettingsP = ({
data.locale,
Number(data.rootBoardId),
data.customDomain,
data.allowAnonymousFeedback,
data.feedbackApprovalPolicy,
data.showRoadmapInHeader,
data.collapseBoardsInHeader,
data.showVoteCount,
@@ -106,15 +114,35 @@ const GeneralSiteSettingsP = ({
authenticityToken
).then(res => {
if (res?.status !== HttpStatus.OK) return;
const urlWithoutHash = window.location.href.split('#')[0];
window.history.pushState({}, document.title, urlWithoutHash);
window.location.reload();
});
};
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 (
<>
<Box>
<Box customClass="generalSiteSettingsContainer">
<h2>{ I18n.t('site_settings.general.title') }</h2>
<form onSubmit={handleSubmit(onSubmit)}>
@@ -226,64 +254,98 @@ const GeneralSiteSettingsP = ({
</div>
}
<br />
<h4>{ I18n.t('site_settings.general.subtitle_header') }</h4>
<div className="formGroup">
<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 id="moderation" className="settingsGroup">
<h4>{ I18n.t('site_settings.general.subtitle_moderation') }</h4>
<div className="formGroup">
<div className="checkboxSwitch">
<input {...register('allowAnonymousFeedback')} type="checkbox" id="allow_anonymous_feedback" />
<label htmlFor="allow_anonymous_feedback">{ getLabel('tenant_setting', 'allow_anonymous_feedback') }</label>
<SmallMutedText>
{ I18n.t('site_settings.general.allow_anonymous_feedback_help') }
</SmallMutedText>
</div>
</div>
</div>
<br />
<div className="formGroup">
<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 />
<h4>{ I18n.t('site_settings.general.subtitle_visibility') }</h4>
<div className="formGroup">
<div className="checkboxSwitch">
<input {...register('showVoteCount')} type="checkbox" id="show_vote_count_checkbox" />
<label htmlFor="show_vote_count_checkbox">{ getLabel('tenant_setting', 'show_vote_count') }</label>
<SmallMutedText>
{ I18n.t('site_settings.general.show_vote_count_help') }
</SmallMutedText>
<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>
<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 id="header" className="settingsGroup">
<h4>{ I18n.t('site_settings.general.subtitle_header') }</h4>
<div className="formGroup">
<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">
<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>
</div>
<br />
<div id="visibility" className="settingsGroup">
<br />
<h4>{ I18n.t('site_settings.general.subtitle_visibility') }</h4>
<div className="formGroup">
<div className="checkboxSwitch">
<input {...register('showPoweredBy')} type="checkbox" id="show_powered_by_checkbox" />
<label htmlFor="show_powered_by_checkbox">{ getLabel('tenant_setting', 'show_powered_by') }</label>
<div className="formGroup">
<div className="checkboxSwitch">
<input {...register('showVoteCount')} type="checkbox" id="show_vote_count_checkbox" />
<label htmlFor="show_vote_count_checkbox">{ getLabel('tenant_setting', 'show_vote_count') }</label>
<SmallMutedText>
{ I18n.t('site_settings.general.show_vote_count_help') }
</SmallMutedText>
</div>
</div>
<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="checkboxSwitch">
<input {...register('showPoweredBy')} type="checkbox" id="show_powered_by_checkbox" />
<label htmlFor="show_powered_by_checkbox">{ getLabel('tenant_setting', 'show_powered_by') }</label>
</div>
</div>
</div>

View File

@@ -43,7 +43,13 @@ const Tour = ({ userFullName }: Props) => {
{
target: '.siteSettingsDropdown',
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,
},
{
@@ -107,8 +113,8 @@ const Tour = ({ userFullName }: Props) => {
// Open profile navbar
if (
state.type === 'step:after' &&
(((state.action === 'next' || state.action === 'close') && (state.step.target === '.sidebarFilters' || state.step.target === '.siteSettingsDropdown')) ||
(state.action === 'prev' && state.step.target === '.tourDropdown'))
(((state.action === 'next' || state.action === 'close') && (state.step.target === '.sidebarFilters' || state.step.target === '.siteSettingsDropdown' || state.step.target === '.moderationDropdown')) ||
(state.action === 'prev' && (state.step.target === '.moderationDropdown' || state.step.target === '.tourDropdown')))
) {
if (vw < BOOTSTRAP_BREAKPOINT_SM) openBoardsNav();

View File

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

View File

@@ -2,14 +2,21 @@ import * as React from 'react';
import I18n from 'i18n-js';
import { 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 { TbLock, TbLockOpen } from 'react-icons/tb';
import { MdContentCopy, MdDone, MdOutlineArrowBack } from 'react-icons/md';
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 {
MdContentCopy,
MdDone,
MdOutlineArrowBack,
MdOutlineLibraryBooks,
MdVerified,
MdCheck,
MdClear,
} from 'react-icons/md';
import { FaUserNinja } from "react-icons/fa";
export const EditIcon = () => <FiEdit />;
@@ -43,4 +50,12 @@ export const ClearIcon = () => <GrClearOption />;
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 />;