[deploy] New Feature: Mod Actions Panel (#7777)
* db * Add an iframe to host mod actions menu This iframe is probably the fastest way to get a working mod actions menu without rewriting existing code. We may eventually re-write this, but based on Ben's recommendation we are going to go with an iframe because it gets us a working feature quickly and it seems to be serving us well in other parts of the application. * Add the mod actions menu button to articles When the user is "trusted" they will see a small badge on the lower right-hand side of the screen. Clicking the icon reveals the mod actions menu. * Add header to actions panel (#7395) * Update iframe border * Add 'Moderate Post' header and button * Use inline SVG over <img src='svg'> * Use Crayons variables for units * Use flexbox to center chevron * Add accidentally deleted positioning * Add chevron-right.svg * Use a partial for actions panel and revert /mod page * Load actions panel in iframe and not /mod * Style UpVote, DownVote and Vomit buttons (#7421) * Add a iframe to host mod actions menu * Up and Downvote Icons * Starting with vomit button * Completed UI; interactions pending * Incorporated Andy changes; making mod action-panel * space reactions away from header * UI tweaks and corrections 1 * Fixing styles to match crayons * choose pure white color css variable * Add thumbs-up emoji; address Lisa comments * Trying to fix reactions interactions * remove clearThumbReactions function Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com> * Add bottom layout for actions panel (#7501) * WIP for Lisa * Finalize mod actions bottom section * Use showing instead of hidden for transition * Add new optimized SVGs * Fix weird height issues for actions panel * Move hover styles below the cascade, yo * Use margin-top for sticky footer over height: 100% * Move initialize actions panel button to Webpack (#7506) * Move actions panel JS code to webpack * Refactor and use destructuring Thanks @nickytonline! Co-Authored-By: Nick Taylor <nick@iamdeveloper.com> * Rename file Co-authored-by: Nick Taylor <nick@iamdeveloper.com> * Configure Experience Level options for Mod Action Panel (#7507) * Add a iframe to host mod actions menu * Click interactions (no toggle of dropdowns yet) Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com> * Fix the iframe's path (#7509) * Toggle the 'Set Experience Level' menu on button-click (#7529) * Add a iframe to host mod actions menu * Add set-exp dropdown functionality * WIP: making exp level updates async * Completes async implementation * Fix controller for "actions_panel" and "/mod" Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com> * Add close button interaction (#7552) * Rename actions panel pack file * Add close button toggle to actions panel * DB update * Move more code to Webpack and add conditional tag adjustments section (#7652) * Move JS code to Webpack * Clean up a few style issues * Add styling for tag adjustments * Add active/inactive functionality * Add conditional rendering for tag adjustments section * Add missing variable oops (#7673) * update with upstream * Add missing subtract svg (#7695) * Create 'Flag User' modal (#7626) * Add a iframe to host mod actions menu * Starting task * Phase 1 * Fix "Close Modal" icon * Implement open modal * Working on all "Close Modal" actions * Almost-complete implementation * Remove Abuse Report snackbar * Delete unneeded JS code * Remove "How does this work?" links * Link to Community Moderation page * Addressing code review corrections * Update api call to use "request" method * Make else path clearer in api call Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com> * Mod Actions Panel: Prevent nonsensical reaction pairs (#7699) * Still implementing * Completed Implementation * Address code review feednack; refactor "create" method * Move scss variables to native-css (for ModPanel code only) * Correct missing native css * update to native-css again * More sccs -> native-css fixes * Adjust ModPanel Button position on mobile (#7744) * Adjusted ModPanel position on mobile * Better adjustment * Fix phantom schema.rb changes * Fix phantom schema.rb changes * Finalize tag adjustments (#7740) * Finalize tag adjustments and flow * Fix logic for rendering adjust tags options * Clean up alert response * Rename user to trusted user * Use new CSS variables * Add/remove tag on article's tag section after adjusting * Fix logic for admin tag input and some other niceties * Final bug fixes mod panel (#7772) * Use correct boolean statement * Clear the value of the textarea not div * Use the correct attribute * Use actions panel for /mod page (#7775) * Fix loading logic with tag adjust button conditional render * Link to tag moderation for adjust tags section * Use top over window.parent Thanks @nickytonline! Co-authored-by: Nick Taylor <nick@iamdeveloper.com> * Use Webpack alias to import Co-authored-by: Nick Taylor <nick@iamdeveloper.com> * Use place-items shorthand over align-items + justify-content Thanks @nickytonline! Co-authored-by: Nick Taylor <nick@iamdeveloper.com> * Fix typo for utilities * Add new line to end of file * Disable linter for use for top function * Use align-items and justify-content center * Update SVGs to spec * Remove unnecessary height and use spacing units for open button * Remove accidentally added comment * Error handling when selecting reactions; optimize "reactions.each" loop * Add comment about clearing innerHTML * Revert schema back to master * Use each since there should only be two reactions * Fix typo oops * Add missing height oops * Return if there are no reactions to be destroyed * Center path with GUI tool shrug * chose more appropriate name for JS pack; rewrote api calls using async-await * Rename variables and methods and check categories before clearing * Use addSnackbarItem instead of hardcoded version * Place articleModerationTools pack behind user_signed_in * Correct omission * Implement dynamic imports for ModPanel tools * Implemented async/await; added snackbar message for null response use-case * Fix the "/article/mod" page; make sure all reactions, vomit-user and exp-level working as expected * Update language to reflect new UI * Close the modal after the "Confirm Action" button is clicked * Change text for Spam/Abuse button * Fix schema to use with master * Update schema to master again * Use crayons button for actions panel button * UI tweak to play nicer with site themes * Make actions panel work for dev.to/mod * Add snackbar for reactions * Update how does this work links * Add "thumbsup" reactions to logging * Add thumbsup to categories * Add external link icon * Changes to reflect "Privileged" and not "Negative" reactions; spec changes pending * Updated instance variable * Starting to update the specs * Update "thumbsup" points from 10 to 5 * Still working on both requests * Embracing an easier implemantation (thanks Andy!) * Add tests for actions panel * Add some basic tests for actions panel * Change to flag to admins * Indicate that 'Reason for Tag adjust' is required Co-authored-by: Mac Siri <krairit.siri@gmail.com> * Rename negative_reactions to privileged * Update rating level to use correct values * Use a different display value than backend value * Don't show adjust tags for trusted users * Add specs for moderation_routes for admin and super_admin Co-authored-by: Arit Amana <msarit@gmail.com> Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com> Co-authored-by: Arit Amana <32520970+msarit@users.noreply.github.com> Co-authored-by: Nick Taylor <nick@iamdeveloper.com> Co-authored-by: Mac Siri <krairit.siri@gmail.com>
3
app/assets/images/book.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.562 3.983h-14c-1.54 0-2.502 1.666-1.732 3 .357.619 1.017 1 1.732 1h14v13a1 1 0 01-1 1h-13a4 4 0 01-4-4v-12a4 4 0 014-4h13a1 1 0 011 1v1zm-16 14a2 2 0 002 2h12v-10h-12a3.984 3.984 0 01-2-.535v8.535zm15-11h-13a1 1 0 010-2h13v2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
3
app/assets/images/chevron-right.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.172 12L8.22198 7.04999L9.63598 5.63599L16 12L9.63598 18.364L8.22198 16.95L13.172 12Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 192 B |
1
app/assets/images/chevron-toggle.svg
Executable file
|
|
@ -0,0 +1 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6 6L0 0h12L6 6z"/></svg>
|
||||
|
After Width: | Height: | Size: 118 B |
BIN
app/assets/images/emoji/emoji-one-thumbs-up-gray.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/assets/images/emoji/emoji-one-thumbs-up.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
1
app/assets/images/mod-filled.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3.783 2.826L12 1l8.217 1.826a1 1 0 01.783.976v9.987a6 6 0 01-2.672 4.992L12 23l-6.328-4.219A6 6 0 013 13.79V3.802a1 1 0 01.783-.976zM13 10V5l-5 7h3v5l5-7h-3z"/></svg>
|
||||
|
After Width: | Height: | Size: 260 B |
3
app/assets/images/plus.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg fill="none" height="24" width="24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z" fill="#08090a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 148 B |
3
app/assets/images/subtract.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="14" height="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 0h14v2H0V0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 101 B |
|
|
@ -55,11 +55,6 @@ function addRelevantButtonsToArticle(user) {
|
|||
);
|
||||
}
|
||||
document.getElementById('action-space').innerHTML = actions.join('');
|
||||
} else if (user.trusted) {
|
||||
document.getElementById('action-space').innerHTML =
|
||||
'<a href="' +
|
||||
articleContainer.dataset.path +
|
||||
'/mod" rel="nofollow">MODERATE <span class="post-word">POST</span></a>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +94,20 @@ function addRelevantButtonsToComments(user) {
|
|||
|
||||
function initializeBaseUserData() {
|
||||
const user = userData();
|
||||
const userProfileLinkHTML =
|
||||
'<a href="/' +
|
||||
user.username +
|
||||
'" id="first-nav-link" class="crayons-link crayons-link--block"><div>' +
|
||||
'<span class="fw-medium block">' +
|
||||
user.name +
|
||||
'</span>' +
|
||||
'<small class="fs-s color-base-50">@' +
|
||||
user.username +
|
||||
'</small>' +
|
||||
'</div></a>';
|
||||
document.getElementById(
|
||||
'user-profile-link-placeholder',
|
||||
).innerHTML = userProfileLinkHTML;
|
||||
const userNavLink = document.getElementById('first-nav-link');
|
||||
userNavLink.href = `/${user.username}`;
|
||||
userNavLink.querySelector('span').textContent = user.name;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,92 @@
|
|||
height: 10px;
|
||||
}
|
||||
|
||||
.mod-actions-menu {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
right: 0;
|
||||
height: calc(100vh - var(--header-height));
|
||||
z-index: 200;
|
||||
transform: translateX(360px);
|
||||
transition: var(--transition-props);
|
||||
&.showing {
|
||||
transform: initial;
|
||||
}
|
||||
|
||||
iframe {
|
||||
height: 100%;
|
||||
border: none;
|
||||
box-shadow: -4px 0px 15px rgba(0, 0, 0, 0.1);
|
||||
width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.mod-actions-menu-btn {
|
||||
position: fixed;
|
||||
bottom: var(--su-7);
|
||||
right: var(--su-7);
|
||||
z-index: 201;
|
||||
cursor: pointer;
|
||||
transition: 0.3s all ease;
|
||||
border-radius: 50px;
|
||||
|
||||
@media (max-width: $breakpoint-m) {
|
||||
bottom: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions-menu-svg {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mod-actions-menu-btn,
|
||||
.actions-menu-svg {
|
||||
transition: 0.3s all ease;
|
||||
}
|
||||
|
||||
.actions-menu-svg:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.flag-user-modal {
|
||||
.flag-user-modal-header {
|
||||
padding-left: var(--su-4);
|
||||
|
||||
h2 {
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-l);
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.modal-header-close-icon {
|
||||
border-radius: 100%;
|
||||
background: var(--base-10);
|
||||
}
|
||||
}
|
||||
|
||||
.flag-user-modal-body {
|
||||
padding: var(--su-6);
|
||||
|
||||
span {
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--base-90);
|
||||
}
|
||||
|
||||
.crayons-fields {
|
||||
margin-top: var(--su-4);
|
||||
|
||||
.abuse-report-link {
|
||||
margin: var(--su-4) 0 0 var(--su-7);
|
||||
}
|
||||
}
|
||||
|
||||
.buttons-container {
|
||||
margin-top: var(--su-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-player-header {
|
||||
background: $black;
|
||||
margin-top: 0;
|
||||
|
|
|
|||
|
|
@ -139,13 +139,17 @@
|
|||
}
|
||||
.mod-index-row-iframes {
|
||||
padding: 5px 0px;
|
||||
display: flex;
|
||||
iframe {
|
||||
height: calc(100vh - 100px);
|
||||
max-height: 800px;
|
||||
width: 49%;
|
||||
width: 100%;
|
||||
border: 0px;
|
||||
display: inline-block;
|
||||
}
|
||||
.actions-panel {
|
||||
width: 36%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,8 +159,452 @@
|
|||
padding: 30px 10px;
|
||||
}
|
||||
|
||||
// these styles are to correct height and margin issues in the iframe
|
||||
.moderations-actions_panel {
|
||||
min-height: initial;
|
||||
height: 100%;
|
||||
#page-content-inner {
|
||||
margin-bottom: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.mod-container {
|
||||
padding: 30px 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--base-0);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
margin-bottom: 0;
|
||||
height: 100%;
|
||||
.circle {
|
||||
border-radius: 100%;
|
||||
&.centered-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
&.top-header {
|
||||
background-color: var(--base-inverted);
|
||||
padding: var(--su-4) var(--su-4) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
&.other-things {
|
||||
padding: var(--su-4);
|
||||
h1 {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--fs-s);
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--fs-l);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-normal);
|
||||
color: var(--base-70);
|
||||
}
|
||||
button.close-actions-panel {
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
position: absolute;
|
||||
right: var(--su-4);
|
||||
top: var(--su-4);
|
||||
background-color: var(--base-10);
|
||||
}
|
||||
}
|
||||
.reactions-container {
|
||||
padding: var(--su-4);
|
||||
background-color: var(--base-inverted);
|
||||
}
|
||||
|
||||
.thumb-reactions-container {
|
||||
display: grid;
|
||||
grid-gap: var(--su-4);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.reaction-button {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: var(--su-10);
|
||||
padding: var(--su-4) var(--su-6);
|
||||
border-radius: var(--radius);
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 73, 223, 0.05);
|
||||
}
|
||||
|
||||
&:hover .reaction-button-circle {
|
||||
border-color: var(--accent-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-button-circle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
width: var(--su-9);
|
||||
height: var(--su-9);
|
||||
border: solid 2px var(--base-20);
|
||||
border-radius: 100%;
|
||||
background: var(--base-inverted);
|
||||
|
||||
img {
|
||||
width: var(--su-7);
|
||||
}
|
||||
|
||||
.reaction-checkmark {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: var(--su-5);
|
||||
height: var(--su-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--accent-success);
|
||||
border-radius: 100%;
|
||||
border: 2px solid var(--base-inverted);
|
||||
padding: 2px;
|
||||
color: var(--base-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
.vote-text {
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-base);
|
||||
margin-top: var(--su-2);
|
||||
color: var(--base-90);
|
||||
}
|
||||
|
||||
.reaction-vomit-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--base-inverted);
|
||||
height: var(--su-7);
|
||||
padding: var(--su-1) var(--su-2);
|
||||
margin: var(--su-4) auto;
|
||||
border-radius: var(--radius);
|
||||
|
||||
img {
|
||||
margin-right: var(--su-2);
|
||||
width: var(--su-6);
|
||||
height: var(--su-6);
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: var(--fw-medium);
|
||||
font-size: var(--fs-s);
|
||||
line-height: var(--lh-tight);
|
||||
color: var(--base-90);
|
||||
}
|
||||
|
||||
.vomit-checkmark {
|
||||
width: var(--su-5);
|
||||
height: auto;
|
||||
font-weight: var(--fw-heavy);
|
||||
margin-left: var(--su-2);
|
||||
color: var(--accent-brand);
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 73, 223, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.how-this-works-section {
|
||||
font-weight: var(--fw-medium);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: var(--lh-base);
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--base-70);
|
||||
cursor: pointer;
|
||||
padding: var(--su-4) var(--su-3);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
svg {
|
||||
width: var(--su-4);
|
||||
height: var(--su-4);
|
||||
padding: var(--su-1);
|
||||
path {
|
||||
fill: var(--base-70);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-button.reacted {
|
||||
background: var(--accent-brand);
|
||||
|
||||
.reaction-button-circle {
|
||||
border-color: var(--accent-brand-lighter);
|
||||
}
|
||||
|
||||
.vote-text {
|
||||
color: var(--base-inverted);
|
||||
}
|
||||
|
||||
.reaction-checkmark {
|
||||
opacity: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-vomit-button.reacted {
|
||||
background: rgba(59, 73, 223, 0.1);
|
||||
|
||||
.vomit-checkmark {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--accent-brand);
|
||||
|
||||
&:after {
|
||||
content: 'Marked as abuse/spam';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-button .reacted-emoji,
|
||||
.reaction-vomit-button .reacted-emoji {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reaction-button.reacted .emoji-grey,
|
||||
.reaction-vomit-button.reacted .emoji-grey {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reaction-button.reacted .reacted-emoji,
|
||||
.reaction-vomit-button.reacted .reacted-emoji {
|
||||
display: block;
|
||||
}
|
||||
.other-things-container {
|
||||
border-top: 1px solid var(--base-20);
|
||||
overflow-y: auto;
|
||||
button.other-things-btn {
|
||||
background: inherit;
|
||||
border: none;
|
||||
padding: var(--su-3) var(--su-4);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.label-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.icon {
|
||||
background-color: var(--base-20);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
header {
|
||||
text-align: left;
|
||||
margin-left: var(--su-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 240px;
|
||||
h2 {
|
||||
color: var(--base-100);
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
h3 {
|
||||
color: var(--base-70);
|
||||
font-size: var(--fs-s);
|
||||
font-weight: var(--fw-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
.toggle-chevron-container {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
transition-duration: 0.4s;
|
||||
svg {
|
||||
fill: var(--base-80);
|
||||
}
|
||||
&.rotated {
|
||||
transform: rotateZ(-180deg);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--base-inverted);
|
||||
.label-wrapper > .icon {
|
||||
background-color: var(--accent-brand);
|
||||
svg {
|
||||
fill: var(--base-inverted);
|
||||
}
|
||||
}
|
||||
header h2 {
|
||||
color: var(--accent-brand);
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
header h2 {
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
}
|
||||
&.inactive:not(.active) {
|
||||
header h2 {
|
||||
color: var(--base-50);
|
||||
}
|
||||
header h3 {
|
||||
color: var(--base-40);
|
||||
}
|
||||
.icon {
|
||||
background-color: var(--base-20);
|
||||
svg {
|
||||
fill: var(--base-50);
|
||||
}
|
||||
}
|
||||
.toggle-chevron-container > svg {
|
||||
fill: var(--base-60);
|
||||
}
|
||||
}
|
||||
}
|
||||
.dropdown-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--su-4);
|
||||
transition-duration: 1s;
|
||||
}
|
||||
.adjust-tags-options {
|
||||
.add-tag-container {
|
||||
padding: var(--su-2) var(--su-1);
|
||||
}
|
||||
button.adjustable-tag {
|
||||
background: inherit;
|
||||
border: none;
|
||||
padding: var(--su-2) 2px;
|
||||
font-size: var(--fs-base);
|
||||
text-align: left;
|
||||
color: var(--base-80);
|
||||
font-family: var(--ff-accent);
|
||||
.num-sign {
|
||||
color: var(--base-40);
|
||||
padding-left: 2px;
|
||||
}
|
||||
.adjustment-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
float: right;
|
||||
margin-right: 2px;
|
||||
&.subtract {
|
||||
background: var(--accent-danger);
|
||||
}
|
||||
&.plus {
|
||||
background: var(--accent-success-darker);
|
||||
}
|
||||
svg > path {
|
||||
fill: var(--base-inverted);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: rgba(59, 73, 223, 0.05);
|
||||
}
|
||||
&.active {
|
||||
background: rgba(59, 73, 223, 0.05);
|
||||
}
|
||||
// &.selected {}
|
||||
}
|
||||
.adjustment-reason-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--su-1);
|
||||
textarea {
|
||||
margin-bottom: var(--su-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.set-experience-options {
|
||||
button.level-rating-button {
|
||||
background: inherit;
|
||||
border: none;
|
||||
padding: var(--su-2) 0.125rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 73, 223, 0.05);
|
||||
}
|
||||
|
||||
.level-rating-content {
|
||||
display: flex;
|
||||
|
||||
.level-rating-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--fw-heavy);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--base-100);
|
||||
width: var(--su-6);
|
||||
}
|
||||
|
||||
.level-rating-text {
|
||||
font-weight: var(--fw-normal);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--base-90);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--accent-brand);
|
||||
width: var(--su-5);
|
||||
height: auto;
|
||||
margin-right: var(--su-2);
|
||||
align-self: center;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.level-rating-button.selected {
|
||||
background: rgba(59, 73, 223, 0.05);
|
||||
svg {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.bottom-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: var(--su-4);
|
||||
margin-top: auto;
|
||||
button {
|
||||
display: initial;
|
||||
font-size: var(--fs-base);
|
||||
color: var(--accent-brand);
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
a {
|
||||
color: var(--body-color);
|
||||
|
|
@ -184,39 +632,14 @@
|
|||
margin: 90px auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.reaction-button {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: white;
|
||||
border-color: white;
|
||||
margin: 30px 30px;
|
||||
border-radius: 200px;
|
||||
border: 0px;
|
||||
padding: 12px 12px;
|
||||
&:active {
|
||||
border: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-button img {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
width: 70px;
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.reaction-button .reacted-emoji {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reaction-button.reacted .reacted-emoji {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tag-mod-form {
|
||||
border-top: 1px solid #dddddd;
|
||||
margin: 42px auto;
|
||||
|
||||
.level-rating-button.article-mod-page.selected {
|
||||
background: #9bebff;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-mod-form ul {
|
||||
|
|
@ -272,19 +695,6 @@
|
|||
border: 1px solid var(--card-color-tertiary);
|
||||
}
|
||||
|
||||
.level-rating-button {
|
||||
border-radius: 8px;
|
||||
font-size: 1.1em;
|
||||
padding: 3px;
|
||||
width: 42px;
|
||||
border: 2px solid black;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.level-rating-button.selected {
|
||||
background: #9bebff;
|
||||
}
|
||||
|
||||
input.undo {
|
||||
background: $red;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class Internal::ApplicationController < ApplicationController
|
|||
{ name: "listings", controller: "listings" },
|
||||
{ name: "moderator_actions", controller: "moderator_actions" },
|
||||
{ name: "mods", controller: "mods" },
|
||||
{ name: "negative_reactions", controller: "negative_reactions" },
|
||||
{ name: "privileged_reactions", controller: "privileged_reactions" },
|
||||
{ name: "organizations", controller: "organizations" },
|
||||
{ name: "path_redirects", controller: "path_redirects" },
|
||||
{ name: "pages", controller: "pages" },
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
class Internal::NegativeReactionsController < Internal::ApplicationController
|
||||
layout "internal"
|
||||
|
||||
NEGATIVE_REACTION_CATEGORIES = %i[vomit thumbsdown].freeze
|
||||
|
||||
def index
|
||||
@q = Reaction.
|
||||
includes(:user,
|
||||
:reactable).
|
||||
where("category IN (?)", NEGATIVE_REACTION_CATEGORIES).
|
||||
order("reactions.created_at DESC").
|
||||
ransack(params[:q])
|
||||
@negative_reactions = @q.result.page(params[:page] || 1).per(25)
|
||||
end
|
||||
end
|
||||
15
app/controllers/internal/privileged_reactions_controller.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
class Internal::PrivilegedReactionsController < Internal::ApplicationController
|
||||
layout "internal"
|
||||
|
||||
PRIVILEGED_REACTION_CATEGORIES = %i[thumbsup thumbsdown vomit].freeze
|
||||
|
||||
def index
|
||||
@q = Reaction.
|
||||
includes(:user,
|
||||
:reactable).
|
||||
where("category IN (?)", PRIVILEGED_REACTION_CATEGORIES).
|
||||
order("reactions.created_at DESC").
|
||||
ransack(params[:q])
|
||||
@privileged_reactions = @q.result.page(params[:page] || 1).per(25)
|
||||
end
|
||||
end
|
||||
|
|
@ -15,6 +15,31 @@ class ModerationsController < ApplicationController
|
|||
end
|
||||
|
||||
def article
|
||||
load_article
|
||||
render template: "moderations/mod"
|
||||
end
|
||||
|
||||
def comment
|
||||
authorize(User, :moderation_routes?)
|
||||
@moderatable = Comment.find(params[:id_code].to_i(26))
|
||||
render template: "moderations/mod"
|
||||
end
|
||||
|
||||
def actions_panel
|
||||
load_article
|
||||
tag_mod_tag_ids = @tag_moderator_tags.pluck(:id)
|
||||
has_room_for_tags = @moderatable.tag_list.size < 4
|
||||
has_no_relevant_adjustments = @adjustments.pluck(:tag_id).intersection(tag_mod_tag_ids).size.zero?
|
||||
can_be_adjusted = @moderatable.tags.pluck(:id).intersection(tag_mod_tag_ids).size.positive?
|
||||
|
||||
@should_show_adjust_tags = tag_mod_tag_ids.size.positive? && ((has_room_for_tags && has_no_relevant_adjustments) || (!has_room_for_tags && has_no_relevant_adjustments && can_be_adjusted))
|
||||
|
||||
render template: "moderations/actions_panel"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_article
|
||||
authorize(User, :moderation_routes?)
|
||||
@tag_adjustment = TagAdjustment.new
|
||||
@moderatable = Article.find_by(slug: params[:slug])
|
||||
|
|
@ -24,12 +49,5 @@ class ModerationsController < ApplicationController
|
|||
@already_adjusted_tags = @adjustments.map(&:tag_name).join(", ")
|
||||
@allowed_to_adjust = @moderatable.class.name == "Article" && (current_user.has_role?(:super_admin) || @tag_moderator_tags.any?)
|
||||
@hidden_comments = @moderatable.comments.where(hidden_by_commentable_user: true)
|
||||
render template: "moderations/mod"
|
||||
end
|
||||
|
||||
def comment
|
||||
authorize(User, :moderation_routes?)
|
||||
@moderatable = Comment.find(params[:id_code].to_i(26))
|
||||
render template: "moderations/mod"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,9 +12,15 @@ class RatingVotesController < ApplicationController
|
|||
rating_vote.rating = rating_vote_params[:rating].to_f
|
||||
rating_vote.group = rating_vote_params[:group]
|
||||
if rating_vote.save
|
||||
redirect_back(fallback_location: "/mod")
|
||||
respond_to do |format|
|
||||
format.json { render json: { result: "Success" } }
|
||||
format.html { redirect_back(fallback_location: "/mod") }
|
||||
end
|
||||
else
|
||||
render json: { result: "Not Upserted Successfully" }
|
||||
respond_to do |format|
|
||||
format.json { render json: { error: rating_vote.errors.full_messages.to_sentence }, status: :unprocessable_entity }
|
||||
format.html { render json: { result: "Not Upserted Successfully" } }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ class ReactionsController < ApplicationController
|
|||
before_action :authorize_for_reaction, :check_limit, only: [:create]
|
||||
after_action :verify_authorized
|
||||
|
||||
NEGATIVE_CATEGORIES = %w[thumbsdown vomit].freeze
|
||||
MODERATION_CATEGORIES = %w[thumbsup thumbsdown vomit].freeze
|
||||
|
||||
def index
|
||||
skip_authorization
|
||||
|
||||
|
|
@ -51,7 +54,17 @@ class ReactionsController < ApplicationController
|
|||
def create
|
||||
remove_count_cache_key
|
||||
|
||||
if params[:reactable_type] == "Article" && params[:category].in?(MODERATION_CATEGORIES)
|
||||
clear_moderator_reactions(
|
||||
params[:reactable_id],
|
||||
params[:reactable_type],
|
||||
current_user,
|
||||
params[:category],
|
||||
)
|
||||
end
|
||||
|
||||
category = params[:category] || "like"
|
||||
|
||||
reaction = Reaction.where(
|
||||
user_id: current_user.id,
|
||||
reactable_id: params[:reactable_id],
|
||||
|
|
@ -61,13 +74,7 @@ class ReactionsController < ApplicationController
|
|||
|
||||
# if the reaction already exists, destroy it
|
||||
if reaction
|
||||
result = destroy_reaction(reaction)
|
||||
|
||||
if reaction.negative? && current_user.auditable?
|
||||
updated_params = params.dup
|
||||
updated_params[:action] = "destroy"
|
||||
Audit::Logger.log(:moderator, current_user, updated_params)
|
||||
end
|
||||
result = handle_existing_reaction(reaction)
|
||||
else
|
||||
reaction = build_reaction(category)
|
||||
|
||||
|
|
@ -84,7 +91,7 @@ class ReactionsController < ApplicationController
|
|||
rate_article(reaction)
|
||||
end
|
||||
|
||||
if reaction.negative? && current_user.auditable?
|
||||
if current_user.auditable?
|
||||
Audit::Logger.log(:moderator, current_user, params.dup)
|
||||
end
|
||||
else
|
||||
|
|
@ -130,6 +137,30 @@ class ReactionsController < ApplicationController
|
|||
rating: current_user.experience_level)
|
||||
end
|
||||
|
||||
def clear_moderator_reactions(id, type, mod, category)
|
||||
reactions = if category == "thumbsup"
|
||||
Reaction.where(reactable_id: id, reactable_type: type, user: mod).where.not(category: category)
|
||||
elsif category.in?(NEGATIVE_CATEGORIES)
|
||||
Reaction.where(reactable_id: id, reactable_type: type, user: mod, category: "thumbsup")
|
||||
end
|
||||
|
||||
return if reactions.blank?
|
||||
|
||||
reactions.find_each { |reaction| destroy_reaction(reaction) }
|
||||
end
|
||||
|
||||
def handle_existing_reaction(reaction)
|
||||
result = destroy_reaction(reaction)
|
||||
|
||||
if reaction.negative? && current_user.auditable?
|
||||
updated_params = params.dup
|
||||
updated_params[:action] = "destroy"
|
||||
Audit::Logger.log(:moderator, current_user, updated_params)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def check_limit
|
||||
rate_limit!(:reaction_creation)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,8 +17,13 @@ class TagAdjustmentsController < ApplicationController
|
|||
article = service.article
|
||||
if tag_adjustment.save
|
||||
service.update_tags_and_notify
|
||||
redirect_to "#{URI.parse(article.path).path}/mod"
|
||||
tag = tag_adjustment.tag
|
||||
respond_to do |format|
|
||||
format.json { render json: { status: "Success", result: tag_adjustment.adjustment_type, colors: { bg: tag.bg_color_hex, text: tag.text_color_hex } } }
|
||||
format.html { redirect_to "#{URI.parse(article.path).path}/mod" }
|
||||
end
|
||||
else
|
||||
# TODO: remove this when we move over to full JSON endpoint
|
||||
authorize(User, :moderation_routes?)
|
||||
@tag_adjustment = tag_adjustment
|
||||
@moderatable = article
|
||||
|
|
@ -26,7 +31,10 @@ class TagAdjustmentsController < ApplicationController
|
|||
@adjustments = TagAdjustment.where(article_id: article.id)
|
||||
@already_adjusted_tags = @adjustments.map(&:tag_name).join(", ")
|
||||
@allowed_to_adjust = @moderatable.class.name == "Article" && (current_user.any_admin? || @tag_moderator_tags.any?)
|
||||
render template: "moderations/mod"
|
||||
respond_to do |format|
|
||||
format.json { render json: { error: "Failure: #{tag_adjustment.errors.full_messages.to_sentence}" } }
|
||||
format.html { render template: "moderations/mod" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -37,6 +45,10 @@ class TagAdjustmentsController < ApplicationController
|
|||
@article = Article.find(tag_adjustment.article_id)
|
||||
@article.update!(tag_list: @article.tag_list.add(tag_adjustment.tag_name)) if tag_adjustment.adjustment_type == "removal"
|
||||
@article.update!(tag_list: @article.tag_list.remove(tag_adjustment.tag_name)) if tag_adjustment.adjustment_type == "addition"
|
||||
redirect_to "#{URI.parse(@article.path).path}/mod"
|
||||
respond_to do |format|
|
||||
# TODO: add tag adjustment removal async route in actions panel
|
||||
format.json { render json: { result: "Tag adjustment destroyed" } }
|
||||
format.html { redirect_to "#{URI.parse(@article.path).path}/mod" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
236
app/javascript/actionsPanel/__tests__/actionsPanel.test.js
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/* eslint-disable no-restricted-globals */
|
||||
import fetch from 'jest-fetch-mock';
|
||||
import {
|
||||
addCloseListener,
|
||||
initializeHeight,
|
||||
addReactionButtonListeners,
|
||||
addAdjustTagListeners,
|
||||
} from '../actionsPanel';
|
||||
|
||||
describe('addCloseListener()', () => {
|
||||
test('toggles the mod actions panel and its button on click', () => {
|
||||
document.body.innerHTML = `
|
||||
<body>
|
||||
<div class="mod-actions-menu showing">
|
||||
<button class="close-actions-panel circle centered-icon" type="button" title="Close moderator actions panel">
|
||||
</button>
|
||||
</div>
|
||||
<div id="mod-actions-menu-btn-area">
|
||||
<div class="mod-actions-menu-btn crayons-btn crayons-btn--icon-rounded crayons-btn--s hidden">
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
addCloseListener();
|
||||
|
||||
const closeButton = document.querySelector('.close-actions-panel');
|
||||
closeButton.click();
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const modPanel = top.document.querySelector('.mod-actions-menu');
|
||||
const modPanelBtn = top.document.querySelector('.mod-actions-menu-btn');
|
||||
expect(modPanel.classList).not.toContain('showing');
|
||||
expect(modPanelBtn.classList).not.toContain('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeHeight()', () => {
|
||||
test('it sets the height of the proper elements', () => {
|
||||
document.body.innerHTML = `
|
||||
<body>
|
||||
<div id="page-content">
|
||||
<div id="page-content-inner">
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
initializeHeight();
|
||||
|
||||
const { body } = document;
|
||||
expect(document.documentElement.style.height).toEqual('100%');
|
||||
expect(body.style.height).toEqual('100%');
|
||||
expect(body.style.margin).toEqual('0px');
|
||||
expect(body.style.marginTop).toEqual('0px');
|
||||
expect(body.style.marginBottom).toEqual('0px');
|
||||
expect(body.style.paddingTop).toEqual('0px');
|
||||
expect(body.style.paddingTop).toEqual('0px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addReactionButtonListeners()', () => {
|
||||
beforeEach(() => {
|
||||
fetch.resetMocks();
|
||||
|
||||
document.body.innerHTML = `
|
||||
<button class="reaction-button" data-reactable-id="1" data-reactable-type="Article" data-category="thumbsup">
|
||||
</button>
|
||||
<button class="reaction-button" data-reactable-id="1" data-reactable-type="Article" data-category="thumbsdown">
|
||||
</button>
|
||||
<button class="reaction-vomit-button" data-reactable-id="1" data-reactable-type="Article" data-category="vomit">
|
||||
</button>
|
||||
`;
|
||||
|
||||
const csrfToken = 'this-is-a-csrf-token';
|
||||
|
||||
window.fetch = fetch;
|
||||
window.getCsrfToken = async () => csrfToken;
|
||||
top.addSnackbarItem = jest.fn();
|
||||
});
|
||||
|
||||
function sampleResponse(category, create = true) {
|
||||
return JSON.stringify({
|
||||
outcome: {
|
||||
result: create ? 'create' : 'destroy',
|
||||
category,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('when no reactions are already reacted on', () => {
|
||||
test('it marks thumbs up reaction as reacted', async () => {
|
||||
let category = 'thumbsup';
|
||||
fetch.mockResponse(sampleResponse(category));
|
||||
addReactionButtonListeners();
|
||||
|
||||
const thumbsupButton = document.querySelector(
|
||||
`.reaction-button[data-category="${category}"]`,
|
||||
);
|
||||
thumbsupButton.click();
|
||||
expect(thumbsupButton.classList).toContain('reacted');
|
||||
|
||||
category = 'thumbsdown';
|
||||
const thumbsdownButton = document.querySelector(
|
||||
`.reaction-button[data-category="${category}"]`,
|
||||
);
|
||||
thumbsdownButton.click();
|
||||
expect(thumbsdownButton.classList).toContain('reacted');
|
||||
|
||||
category = 'vomit';
|
||||
fetch.resetMocks();
|
||||
fetch.mockResponse(sampleResponse(category));
|
||||
const vomitButton = document.querySelector(
|
||||
`.reaction-vomit-button[data-category="${category}"]`,
|
||||
);
|
||||
vomitButton.click();
|
||||
expect(vomitButton.classList).toContain('reacted');
|
||||
});
|
||||
test('it unmarks the proper reaction(s) when positive/negative reactions are clicked', async () => {
|
||||
let category = 'thumbsup';
|
||||
fetch.mockResponse(sampleResponse(category));
|
||||
addReactionButtonListeners();
|
||||
const thumbsupButton = document.querySelector(
|
||||
`.reaction-button[data-category="${category}"]`,
|
||||
);
|
||||
thumbsupButton.click();
|
||||
|
||||
category = 'thumbsdown';
|
||||
fetch.resetMocks();
|
||||
fetch.mockResponse(sampleResponse(category));
|
||||
const thumbsdownButton = document.querySelector(
|
||||
`.reaction-button[data-category="${category}"]`,
|
||||
);
|
||||
thumbsdownButton.click();
|
||||
expect(thumbsupButton.classList).not.toContain('reacted');
|
||||
|
||||
fetch.resetMocks();
|
||||
category = 'thumbsup';
|
||||
fetch.mockResponse(sampleResponse(category, false));
|
||||
thumbsupButton.click();
|
||||
expect(thumbsdownButton.classList).not.toContain('reacted');
|
||||
expect(thumbsupButton.classList).toContain('reacted');
|
||||
|
||||
category = 'vomit';
|
||||
fetch.resetMocks();
|
||||
fetch.mockResponse(sampleResponse(category));
|
||||
const vomitButton = document.querySelector(
|
||||
`.reaction-vomit-button[data-category="${category}"]`,
|
||||
);
|
||||
vomitButton.click();
|
||||
expect(vomitButton.classList).toContain('reacted');
|
||||
expect(thumbsupButton.classList).not.toContain('reacted');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAdjustTagListeners()', () => {
|
||||
describe('when the user is tag moderator of #discuss', () => {
|
||||
beforeEach(() => {
|
||||
const tagName = 'discuss';
|
||||
document.body.innerHTML = `
|
||||
<a href="/t/${tagName}" class="tag">${tagName}</a>
|
||||
<button class="adjustable-tag" data-adjustment-type="subtract" data-tag-name="${tagName}">
|
||||
#${tagName}
|
||||
</button>
|
||||
<form id="adjustment-reason-container" class="adjustment-reason-container hidden">
|
||||
<textarea class="crayons-textfield" placeholder="Reason for tag adjustment (required)" id="tag-adjustment-reason" required></textarea>
|
||||
<button class="crayons-btn" id="tag-adjust-submit" type="submit">Submit</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
addAdjustTagListeners();
|
||||
});
|
||||
describe('when an article is tagged with #discuss', () => {
|
||||
it('toggles the tag button and the form', () => {
|
||||
const tagBtn = document.querySelector('.adjustable-tag');
|
||||
tagBtn.click();
|
||||
expect(tagBtn.classList).toContain('active');
|
||||
expect(
|
||||
document.getElementById('adjustment-reason-container').classList,
|
||||
).not.toContain('hidden');
|
||||
});
|
||||
it('hides the form if the button is clicked again', () => {
|
||||
const tagBtn = document.querySelector('.adjustable-tag');
|
||||
tagBtn.click();
|
||||
tagBtn.click();
|
||||
expect(
|
||||
document.getElementById('adjustment-reason-container').classList,
|
||||
).toContain('hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user is an admin and the article has room for tags', () => {
|
||||
describe('tag adjustment interactions', () => {
|
||||
beforeEach(() => {
|
||||
const tagName = 'discuss';
|
||||
document.body.innerHTML = `
|
||||
<div class="add-tag-container">
|
||||
<input id="admin-add-tag" class="crayons-textfield" type="text" placeholder="Add a tag" data-article-id="1" data-adjustment-type="plus">
|
||||
</div>
|
||||
<a href="/t/${tagName}" class="tag">${tagName}</a>
|
||||
<button class="adjustable-tag" data-adjustment-type="subtract" data-tag-name="${tagName}">
|
||||
#${tagName}
|
||||
</button>
|
||||
<button class="adjustable-tag" data-adjustment-type="subtract" data-tag-name="ruby">
|
||||
#ruby
|
||||
</button>
|
||||
<form id="adjustment-reason-container" class="adjustment-reason-container hidden">
|
||||
<textarea class="crayons-textfield" placeholder="Reason for tag adjustment" id="tag-adjustment-reason" required></textarea>
|
||||
<button class="crayons-btn" id="tag-adjust-submit" type="submit">Submit</button>
|
||||
</form>
|
||||
`;
|
||||
addAdjustTagListeners();
|
||||
});
|
||||
it('shows the adjustment container when admin input is focused', () => {
|
||||
document.getElementById('admin-add-tag').focus();
|
||||
expect(
|
||||
document.getElementById('adjustment-reason-container').classList,
|
||||
).not.toContain('hidden');
|
||||
});
|
||||
it('triggers a confirmation if the admin add tag input was filled in', () => {
|
||||
window.confirm = jest.fn();
|
||||
document.getElementById('admin-add-tag').value = 'pizza';
|
||||
document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click();
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
});
|
||||
it('does not the hide reason container when going from one tag to another tag', () => {
|
||||
document.querySelector('.adjustable-tag').click();
|
||||
document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click();
|
||||
expect(
|
||||
document.getElementById('adjustment-reason-container').classList,
|
||||
).not.toContain('hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* eslint-enable no-restricted-globals */
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import initializeActionsPanel from '../initializeActionsPanelToggle';
|
||||
|
||||
describe('toggling the actions panel', () => {
|
||||
describe('when the page is the article show page', () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="mod-actions-menu"></div>
|
||||
<div id="mod-actions-menu-btn-area"></div>
|
||||
`;
|
||||
const path = '/fakeuser/fake-article-slug-1d3a';
|
||||
|
||||
test('it should render the mod actions menu button', () => {
|
||||
initializeActionsPanel(path);
|
||||
expect(
|
||||
document.querySelector(
|
||||
`iframe#mod-container[src="${path}/actions_panel"]`,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(document.querySelector(`.mod-actions-menu-btn`)).not.toBeNull();
|
||||
expect(document.querySelector(`.actions-menu-svg`)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('it should have a click listener that toggles the appropriate classes', () => {
|
||||
initializeActionsPanel(path);
|
||||
const modActionsMenu = document.querySelector('.mod-actions-menu');
|
||||
const modActionsMenuBtn = document.querySelector('.mod-actions-menu-btn');
|
||||
|
||||
modActionsMenuBtn.click();
|
||||
|
||||
expect(modActionsMenu.classList.contains('showing')).toBeTruthy();
|
||||
expect(modActionsMenuBtn.classList.contains('hidden')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
370
app/javascript/actionsPanel/actionsPanel.js
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { request } from '@utilities/http';
|
||||
|
||||
export function addCloseListener() {
|
||||
const button = document.querySelector('.close-actions-panel');
|
||||
button.addEventListener('click', () => {
|
||||
// getting the article show page document because this is called within an iframe
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const articleDocument = top.document;
|
||||
|
||||
articleDocument
|
||||
.querySelector('.mod-actions-menu')
|
||||
.classList.toggle('showing');
|
||||
articleDocument
|
||||
.querySelector('.mod-actions-menu-btn')
|
||||
.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
export function initializeHeight() {
|
||||
document.documentElement.style.height = '100%';
|
||||
document.body.style.cssText = 'height: 100%; margin: 0; padding-top: 0;';
|
||||
document.getElementById('page-content').style.cssText =
|
||||
'margin-top: 0 !important; margin-bottom: 0;';
|
||||
}
|
||||
|
||||
function toggleDropdown(type) {
|
||||
if (type === 'set-experience') {
|
||||
document
|
||||
.querySelector('.set-experience-options')
|
||||
.classList.toggle('hidden');
|
||||
} else if (type === 'adjust-tags') {
|
||||
document.querySelector('.adjust-tags-options').classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function applyReactedClass(category) {
|
||||
const upVote = document.querySelector("[data-category='thumbsup']");
|
||||
const downVote = document.querySelector("[data-category='thumbsdown']");
|
||||
const vomitVote = document.querySelector("[data-category='vomit']");
|
||||
|
||||
if (category === 'thumbsup') {
|
||||
downVote.classList.remove('reacted');
|
||||
vomitVote.classList.remove('reacted');
|
||||
} else {
|
||||
upVote.classList.remove('reacted');
|
||||
}
|
||||
}
|
||||
|
||||
export function addReactionButtonListeners() {
|
||||
const butts = Array.from(
|
||||
document.querySelectorAll('.reaction-button, .reaction-vomit-button'),
|
||||
);
|
||||
/* eslint-disable camelcase */
|
||||
butts.forEach((butt) => {
|
||||
butt.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
const {
|
||||
reactableType: reactable_type,
|
||||
category,
|
||||
reactableId: reactable_id,
|
||||
} = butt.dataset;
|
||||
|
||||
applyReactedClass(category);
|
||||
butt.classList.toggle('reacted');
|
||||
|
||||
try {
|
||||
const response = await request('/reactions', {
|
||||
method: 'POST',
|
||||
body: { reactable_type, category, reactable_id },
|
||||
});
|
||||
|
||||
const outcome = await response.json();
|
||||
|
||||
let message;
|
||||
/* eslint-disable no-restricted-globals */
|
||||
if (outcome.result === 'create' && outcome.category === 'thumbsup') {
|
||||
message = 'This post will be more visible.';
|
||||
} else if (
|
||||
outcome.result === 'create' &&
|
||||
outcome.category === 'thumbsdown'
|
||||
) {
|
||||
message = 'This post will be less visible.';
|
||||
} else if (
|
||||
outcome.result === 'create' &&
|
||||
outcome.category === 'vomit'
|
||||
) {
|
||||
message = "You've flagged this post as abusive or spam.";
|
||||
} else if (outcome.result === 'destroy') {
|
||||
message = 'Your quality rating was removed.';
|
||||
} else if (outcome.error) {
|
||||
message = `Error: ${outcome.error}`;
|
||||
}
|
||||
top.addSnackbarItem({
|
||||
message,
|
||||
addCloseButton: true,
|
||||
});
|
||||
/* eslint-enable no-restricted-globals */
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
|
||||
function clearExpLevels() {
|
||||
Array.from(
|
||||
document.getElementsByClassName('level-rating-button selected'),
|
||||
).forEach((el) => {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
}
|
||||
|
||||
async function updateExperienceLevel(currentUserId, articleId, rating, group) {
|
||||
try {
|
||||
const response = await request('/rating_votes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user_id: currentUserId,
|
||||
article_id: articleId,
|
||||
rating,
|
||||
group,
|
||||
}),
|
||||
});
|
||||
|
||||
const outcome = await response.json();
|
||||
|
||||
if (outcome.result === 'Success') {
|
||||
clearExpLevels();
|
||||
document
|
||||
.getElementById(`js__rating__vote__${rating}`)
|
||||
.classList.add('selected');
|
||||
} else {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(outcome.error);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSubmitContainer() {
|
||||
document
|
||||
.getElementById('adjustment-reason-container')
|
||||
.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function clearAdjustmentReason() {
|
||||
document.getElementById('tag-adjustment-reason').value = '';
|
||||
}
|
||||
|
||||
function renderTagOnArticle(tagName, colors) {
|
||||
/* eslint-disable no-restricted-globals */
|
||||
let articleTagsContainer;
|
||||
if (top.document.location.pathname.endsWith('/mod')) {
|
||||
[articleTagsContainer] = top.document
|
||||
.getElementById('quick-mod-article')
|
||||
.contentDocument.getElementsByClassName('tags');
|
||||
} else {
|
||||
[articleTagsContainer] = top.document.getElementsByClassName('tags');
|
||||
}
|
||||
/* eslint-enable no-restricted-globals */
|
||||
|
||||
const newTag = document.createElement('a');
|
||||
newTag.innerText = `#${tagName}`;
|
||||
newTag.setAttribute('class', 'tag');
|
||||
newTag.setAttribute('href', `/t/${tagName}`);
|
||||
newTag.style = `background-color: ${colors.bg}; color: ${colors.text};`;
|
||||
|
||||
articleTagsContainer.appendChild(newTag);
|
||||
}
|
||||
|
||||
async function adjustTag(el) {
|
||||
const reasonForAdjustment = document.getElementById('tag-adjustment-reason')
|
||||
.value;
|
||||
const body = {
|
||||
tag_adjustment: {
|
||||
// TODO: change to tag ID
|
||||
tag_name: el.dataset.tagName || el.value,
|
||||
article_id: el.dataset.articleId,
|
||||
adjustment_type:
|
||||
el.dataset.adjustmentType === 'subtract' ? 'removal' : 'addition',
|
||||
reason_for_adjustment: reasonForAdjustment,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await request('/tag_adjustments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const outcome = await response.json();
|
||||
|
||||
if (outcome.status === 'Success') {
|
||||
let adjustedTagName;
|
||||
if (el.tagName === 'BUTTON') {
|
||||
adjustedTagName = el.dataset.tagName;
|
||||
el.remove();
|
||||
} else {
|
||||
adjustedTagName = el.value;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
el.value = '';
|
||||
}
|
||||
|
||||
toggleSubmitContainer();
|
||||
clearAdjustmentReason();
|
||||
|
||||
if (outcome.result === 'addition') {
|
||||
renderTagOnArticle(adjustedTagName, outcome.colors);
|
||||
} else {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const tagOnArticle = top.document.querySelector(
|
||||
`.tag[href="/t/${adjustedTagName}"]`,
|
||||
);
|
||||
tagOnArticle.remove();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
top.addSnackbarItem({
|
||||
message: `The #${adjustedTagName} tag was ${
|
||||
outcome.result === 'addition' ? 'added' : 'removed'
|
||||
}.`,
|
||||
addCloseButton: true,
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
top.addSnackbarItem({
|
||||
message: `An error occurred: ${outcome.error}`,
|
||||
addCloseButton: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAdjustTagBtn(btn) {
|
||||
const currentActiveTags = document.querySelectorAll(
|
||||
'button.adjustable-tag.active',
|
||||
);
|
||||
const adminTagInput = document.getElementById('admin-add-tag');
|
||||
/* eslint-disable no-restricted-globals */
|
||||
/* eslint-disable no-alert */
|
||||
if (
|
||||
adminTagInput &&
|
||||
adminTagInput.value !== '' &&
|
||||
confirm(
|
||||
'This will clear your current "Add a tag" input. Do you want to continue?',
|
||||
)
|
||||
) {
|
||||
/* eslint-enable no-restricted-globals */
|
||||
/* eslint-enable no-alert */
|
||||
adminTagInput.value = '';
|
||||
} else if (currentActiveTags.length > 0) {
|
||||
currentActiveTags.forEach((tag) => {
|
||||
if (tag !== btn) {
|
||||
tag.classList.remove('active');
|
||||
}
|
||||
btn.classList.toggle('active');
|
||||
});
|
||||
if (btn.classList.contains('active')) {
|
||||
document
|
||||
.getElementById('adjustment-reason-container')
|
||||
.classList.remove('hidden');
|
||||
} else {
|
||||
document
|
||||
.getElementById('adjustment-reason-container')
|
||||
.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
btn.classList.toggle('active');
|
||||
toggleSubmitContainer();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdminInput() {
|
||||
const addTagInput = document.getElementById('admin-add-tag');
|
||||
|
||||
if (addTagInput) {
|
||||
addTagInput.addEventListener('focus', () => {
|
||||
document
|
||||
.getElementById('adjustment-reason-container')
|
||||
.classList.remove('hidden');
|
||||
|
||||
const activeTagBtns = Array.from(
|
||||
document.querySelectorAll('button.adjustable-tag.active'),
|
||||
);
|
||||
activeTagBtns.forEach((btn) => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
});
|
||||
addTagInput.addEventListener('focusout', () => {
|
||||
if (addTagInput.value === '') {
|
||||
toggleSubmitContainer();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function addAdjustTagListeners() {
|
||||
Array.from(document.getElementsByClassName('adjustable-tag')).forEach(
|
||||
(btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
handleAdjustTagBtn(btn);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const adjustTagSubmitBtn = document.getElementById('tag-adjust-submit');
|
||||
if (adjustTagSubmitBtn) {
|
||||
adjustTagSubmitBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const textArea = document.getElementById('tag-adjustment-reason');
|
||||
const dataSource =
|
||||
document.querySelector('button.adjustable-tag.active') ||
|
||||
document.getElementById('admin-add-tag');
|
||||
|
||||
if (textArea.checkValidity()) {
|
||||
adjustTag(dataSource);
|
||||
}
|
||||
});
|
||||
|
||||
handleAdminInput();
|
||||
}
|
||||
}
|
||||
|
||||
export function addBottomActionsListeners() {
|
||||
addAdjustTagListeners();
|
||||
Array.from(document.getElementsByClassName('other-things-btn')).forEach(
|
||||
(btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
btn.classList.toggle('active');
|
||||
const otherBtns = Array.from(
|
||||
document.getElementsByClassName('other-things-btn'),
|
||||
).filter((otherBtn) => otherBtn !== btn);
|
||||
otherBtns.forEach((otherBtn) => {
|
||||
otherBtn.classList.toggle('inactive');
|
||||
});
|
||||
|
||||
btn.querySelector('.label-wrapper > .icon').classList.toggle('hidden');
|
||||
btn
|
||||
.querySelector('.toggle-chevron-container')
|
||||
.classList.toggle('rotated');
|
||||
toggleDropdown(btn.dataset.otherThingsType);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
document.querySelectorAll('.level-rating-button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
updateExperienceLevel(
|
||||
btn.dataset.userId,
|
||||
btn.dataset.articleId,
|
||||
btn.value,
|
||||
btn.dataset.group,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function initializeActionsPanel() {
|
||||
initializeHeight();
|
||||
addCloseListener();
|
||||
addReactionButtonListeners();
|
||||
addBottomActionsListeners();
|
||||
}
|
||||
24
app/javascript/actionsPanel/initializeActionsPanelToggle.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** This initializes the mod actions button on the article show page (app/views/articles/show.html.erb). */
|
||||
export default function initializeActionsPanel(user, path) {
|
||||
const modActionsMenuHTML = `<iframe id="mod-container" src=${path}/actions_panel></iframe>`;
|
||||
const modActionsMenuIconHTML = `<div class="mod-actions-menu-btn crayons-btn crayons-btn--icon-rounded crayons-btn--s">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="54px" height="54px" viewBox="-8 -8 40 40" class="crayons-icon actions-menu-svg" role="img" aria-labelledby=""><title id="">Moderation</title><path d="M3.783 2.826L12 1l8.217 1.826a1 1 0 01.783.976v9.987a6 6 0 01-2.672 4.992L12 23l-6.328-4.219A6 6 0 013 13.79V3.802a1 1 0 01.783-.976zM5 4.604v9.185a4 4 0 001.781 3.328L12 20.597l5.219-3.48A4 4 0 0019 13.79V4.604L12 3.05 5 4.604zM13 10h3l-5 7v-5H8l5-7v5z"></path></svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function toggleModActionsMenu() {
|
||||
document.querySelector('.mod-actions-menu').classList.toggle('showing');
|
||||
document.querySelector('.mod-actions-menu-btn').classList.toggle('hidden');
|
||||
}
|
||||
|
||||
document.querySelector('.mod-actions-menu').innerHTML = modActionsMenuHTML;
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
if (!top.document.location.pathname.endsWith('/mod')) {
|
||||
document.getElementById(
|
||||
'mod-actions-menu-btn-area',
|
||||
).innerHTML = modActionsMenuIconHTML;
|
||||
document
|
||||
.querySelector('.mod-actions-menu-btn')
|
||||
.addEventListener('click', toggleModActionsMenu);
|
||||
}
|
||||
}
|
||||
9
app/javascript/packs/actionsPanel.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const loadActionsPanel = async () => {
|
||||
const { initializeActionsPanel } = await import(
|
||||
'../actionsPanel/actionsPanel'
|
||||
);
|
||||
|
||||
initializeActionsPanel();
|
||||
};
|
||||
|
||||
loadActionsPanel();
|
||||
31
app/javascript/packs/articleModerationTools.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/* eslint-disable no-restricted-globals */
|
||||
/* eslint-disable no-undef */
|
||||
const user = userData();
|
||||
const { authorId: articleAuthorId, path } = document.getElementById(
|
||||
'article-show-container',
|
||||
).dataset;
|
||||
|
||||
const initializeModerationsTools = async () => {
|
||||
const { default: initializeActionsPanel } = await import(
|
||||
'../actionsPanel/initializeActionsPanelToggle'
|
||||
);
|
||||
const { default: initializeFlagUserModal } = await import('./flagUserModal');
|
||||
|
||||
// article show page
|
||||
if (
|
||||
user.trusted &&
|
||||
user.id !== articleAuthorId &&
|
||||
!top.document.location.pathname.endsWith('/mod')
|
||||
) {
|
||||
initializeActionsPanel(user, path);
|
||||
initializeFlagUserModal(articleAuthorId);
|
||||
// dev.to/mod
|
||||
} else if (user.trusted && top.document.location.pathname.endsWith('/mod')) {
|
||||
initializeActionsPanel(user, path);
|
||||
initializeFlagUserModal(articleAuthorId);
|
||||
}
|
||||
};
|
||||
|
||||
initializeModerationsTools();
|
||||
/* eslint-enable no-restricted-globals */
|
||||
/* eslint-enable no-undef */
|
||||
|
|
@ -6,6 +6,9 @@ const snackZone = document.getElementById('snack-zone');
|
|||
|
||||
render(<Snackbar lifespan="3" />, snackZone, snackZone.firstElementChild);
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
top.addSnackbarItem = addSnackbarItem;
|
||||
|
||||
const userDataIntervalID = setInterval(async () => {
|
||||
const { user = null, userStatus } = document.body.dataset;
|
||||
|
||||
|
|
|
|||
137
app/javascript/packs/flagUserModal.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { request } from '../utilities/http';
|
||||
|
||||
export default function initializeFlagUserModal(articleAuthorId) {
|
||||
const flagUserModalHTML = `
|
||||
<div class="crayons-modal crayons-modal--s absolute flag-user-modal">
|
||||
<div class="crayons-modal__box">
|
||||
<header class="crayons-modal__box__header flag-user-modal-header">
|
||||
<h2>Flag User</h2>
|
||||
<button type="button" class="crayons-btn crayons-btn--icon crayons-btn--ghost modal-header-close-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="crayons-modal__box__body flag-user-modal-body">
|
||||
<span>
|
||||
Thanks for keeping DEV safe. Here is what you can do to flag this user:
|
||||
</span>
|
||||
<div class="crayons-fields">
|
||||
<div class="crayons-field crayons-field--radio">
|
||||
<input
|
||||
type="radio"
|
||||
id="vomit-all"
|
||||
name="flag-user"
|
||||
class="crayons-radio"
|
||||
data-reactable-id="${articleAuthorId}"
|
||||
data-category="vomit"
|
||||
data-reactable-type="User">
|
||||
<label for="vomit-all" class="crayons-field__label">
|
||||
Make all posts by this author less visible
|
||||
<p class="crayons-field__description">
|
||||
This author consistently posts content that violates DEV's code of conduct because it is harassing, offensive or spammy.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
<a href="/report-abuse?url=${document.location}" class="fs-base abuse-report-link">Report other inappropriate conduct</a>
|
||||
</div>
|
||||
<div class="buttons-container">
|
||||
<a href="#" class="crayons-btn crayons-btn--danger" id="confirm-flag-user-action">Confirm action</a>
|
||||
<a href="#" class="crayons-btn crayons-btn--secondary" id="cancel-flag-user-action">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="crayons-modal__overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const toggleFlagUserModal = () => {
|
||||
const modalContainer = document.querySelector('.flag-user-modal-container');
|
||||
modalContainer.classList.toggle('hidden');
|
||||
|
||||
if (!modalContainer.classList.contains('hidden')) {
|
||||
window.scrollTo(0, 0);
|
||||
document.querySelector('body').style.height = '100vh';
|
||||
document.querySelector('body').style.overflowY = 'hidden';
|
||||
} else {
|
||||
document.querySelector('body').style.height = 'inherit';
|
||||
document.querySelector('body').style.overflowY = 'inherit';
|
||||
}
|
||||
};
|
||||
|
||||
const modContainer = document.getElementById('mod-container');
|
||||
document.querySelector(
|
||||
'.flag-user-modal-container',
|
||||
).innerHTML = flagUserModalHTML;
|
||||
modContainer.addEventListener('load', () => {
|
||||
modContainer.contentWindow.document
|
||||
.getElementById('open-flag-user-modal')
|
||||
.addEventListener('click', toggleFlagUserModal);
|
||||
});
|
||||
|
||||
// Event listeners to Close the Modal
|
||||
const closeModalElements = Array.from(
|
||||
document.querySelectorAll(
|
||||
'.crayons-modal__overlay, .modal-header-close-icon, #cancel-flag-user-action',
|
||||
),
|
||||
);
|
||||
|
||||
closeModalElements.forEach((element) => {
|
||||
element.addEventListener('click', toggleFlagUserModal);
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById('confirm-flag-user-action')
|
||||
.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const vomitAllOption = document.getElementById('vomit-all');
|
||||
|
||||
if (vomitAllOption.checked) {
|
||||
const body = JSON.stringify({
|
||||
reactable_type: vomitAllOption.dataset.reactableType,
|
||||
category: vomitAllOption.dataset.category,
|
||||
reactable_id: vomitAllOption.dataset.reactableId,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request('/reactions', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
const outcome = await response.json();
|
||||
|
||||
if (outcome.result === 'create') {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
top.addSnackbarItem({
|
||||
message: 'All posts by this author will be less visible.',
|
||||
addCloseButton: true,
|
||||
});
|
||||
} else if (outcome.result === null) {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
top.addSnackbarItem({
|
||||
message:
|
||||
"It seems you've already reduced the vibilsity of this author's posts.",
|
||||
addCloseButton: true,
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
top.addSnackbarItem({
|
||||
message: `Response from server: ${JSON.stringify(outcome)}`,
|
||||
addCloseButton: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
top.addSnackbarItem({ message: error, addCloseButton: true });
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
top.addSnackbarItem({
|
||||
message: 'No selection made!',
|
||||
addCloseButton: true,
|
||||
});
|
||||
}
|
||||
toggleFlagUserModal();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
class NegativeReaction < ApplicationRecord
|
||||
class PrivilegedReaction < ApplicationRecord
|
||||
resourcify
|
||||
# This class exists to take advantage of Rolify for limiting authorization
|
||||
# on internal reports.
|
||||
|
|
@ -4,7 +4,7 @@ class Reaction < ApplicationRecord
|
|||
SEARCH_SERIALIZER = Search::ReactionSerializer
|
||||
SEARCH_CLASS = Search::Reaction
|
||||
|
||||
CATEGORIES = %w[like readinglist unicorn thinking hands thumbsdown vomit].freeze
|
||||
CATEGORIES = %w[like readinglist unicorn thinking hands thumbsup thumbsdown vomit].freeze
|
||||
PUBLIC_CATEGORIES = %w[like readinglist unicorn thinking hands].freeze
|
||||
REACTABLE_TYPES = %w[Comment Article User].freeze
|
||||
STATUSES = %w[valid invalid confirmed archived].freeze
|
||||
|
|
@ -161,6 +161,7 @@ class Reaction < ApplicationRecord
|
|||
|
||||
BASE_POINTS = {
|
||||
"vomit" => -50.0,
|
||||
"thumbsup" => 5.0,
|
||||
"thumbsdown" => -10.0
|
||||
}.freeze
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ class UserPolicy < ApplicationPolicy
|
|||
end
|
||||
|
||||
def moderation_routes?
|
||||
user.has_role?(:trusted) && !user.banned
|
||||
(user.has_role?(:trusted) || minimal_admin?) && !user.banned
|
||||
end
|
||||
|
||||
def permitted_attributes
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
<% title @article.title_with_query_preamble(user_signed_in?) %>
|
||||
|
||||
<%= render "shared/webcomponents_loader_script" %>
|
||||
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", defer: true %>
|
||||
<% if user_signed_in? %>
|
||||
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", "articleModerationTools", defer: true %>
|
||||
<% else %>
|
||||
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", defer: true %>
|
||||
<% end %>
|
||||
|
||||
<% cache("content-related-optional-scripts-#{@article.id}-#{@article.updated_at}-#{internal_navigation?}-#{user_signed_in?}", expires_in: 30.hours) do %>
|
||||
<% if @article.processed_html.include? "ltag_gist-liquid-tag" %>
|
||||
|
|
@ -224,6 +228,10 @@
|
|||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mod-actions-menu"></div>
|
||||
<div id="mod-actions-menu-btn-area"></div>
|
||||
<div class="flag-user-modal-container hidden"></div>
|
||||
|
||||
<% cache("article-show-scripts", expires_in: 8.hours) do %>
|
||||
<script async>
|
||||
<%# we consider these scripts safe for embedding as they come from our code %>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
<% if params[:state] == "potential" %>
|
||||
<td>
|
||||
<%= form_with model: [:internal, mod], url: internal_mod_path(mod.id), method: :patch, local: true do |f| %>
|
||||
<%= f.submit "Make Trusted Mod", class: "btn btn-light" %>
|
||||
<%= f.submit "Make Trusted Mod", class: "btn btn-light js-add-to-mod-channel" %>
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
<div class="col">
|
||||
</div>
|
||||
<div class="col">
|
||||
<%= search_form_for @q, url: internal_negative_reactions_path, class: "form-inline justify-content-end" do |f| %>
|
||||
<%= search_form_for @q, url: internal_privileged_reactions_path, class: "form-inline justify-content-end" do |f| %>
|
||||
|
||||
<%= f.label :user_username_cont, "User", class: "sr-only" %>
|
||||
<%= f.search_field :user_username_cont, placeholder: "User", class: "form-control mx-3" %>
|
||||
|
||||
<%= f.select(
|
||||
:category_eq,
|
||||
options_for_select(["", "vomit", "thumbsdown"], @q.category_eq),
|
||||
options_for_select(["", "thumbsup", "thumbsdown", "vomit"], @q.category_eq),
|
||||
{},
|
||||
class: "custom-select mx-3",
|
||||
) %>
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<%= paginate @negative_reactions %>
|
||||
<%= paginate @privileged_reactions %>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @negative_reactions.each do |reaction| %>
|
||||
<% @privileged_reactions.each do |reaction| %>
|
||||
<tr>
|
||||
<td><%= reaction.id %></td>
|
||||
<td><%= link_to reaction.user.username, internal_user_path(reaction.user_id) %></td>
|
||||
|
|
@ -54,4 +54,4 @@
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
<%= paginate @negative_reactions %>
|
||||
<%= paginate @privileged_reactions %>
|
||||
161
app/views/moderations/actions_panel.html.erb
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<% title "Moderating " + @moderatable.title %>
|
||||
|
||||
<% cache(cache_key_heroku_slug("mod-styling")) do %>
|
||||
<style>
|
||||
<%= Rails.application.assets["moderators.css"].to_s.html_safe %>
|
||||
</style>
|
||||
<% end %>
|
||||
|
||||
<%= javascript_packs_with_chunks_tag "actionsPanel", defer: true %>
|
||||
|
||||
<div class="container mod-container">
|
||||
<header class="top-header">
|
||||
<h1>Moderate Post</h1>
|
||||
<h2>Rate the quality of this post</h2>
|
||||
<button class="close-actions-panel circle centered-icon" type="button" title="Close moderator actions panel">
|
||||
<%= inline_svg_tag("chevron-right.svg", aria: false) %>
|
||||
</button>
|
||||
</header>
|
||||
<div class="reactions-container">
|
||||
<div class="thumb-reactions-container">
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsup") ? "reacted" : "" %>"
|
||||
data-reactable-id="<%= @moderatable.id %>"
|
||||
data-category="thumbsup"
|
||||
data-reactable-type="<%= @moderatable.class.name %>">
|
||||
<div class="reaction-button-circle">
|
||||
<%= image_tag("emoji/emoji-one-thumbs-up-gray.png", alt: "Thumbs up emoji", class: "emoji-grey") %>
|
||||
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-thumbs-up.png") %>" alt="Thumbs up emoji" />
|
||||
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
|
||||
</div>
|
||||
<span class="vote-text">High Quality</span>
|
||||
</button>
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsdown") ? "reacted" : "" %>"
|
||||
data-reactable-id="<%= @moderatable.id %>"
|
||||
data-category="thumbsdown"
|
||||
data-reactable-type="<%= @moderatable.class.name %>">
|
||||
<div class="reaction-button-circle">
|
||||
<%= image_tag("emoji/emoji-one-thumbs-down-gray.png", alt: "Thumbs down emoji", class: "emoji-grey") %>
|
||||
<img class="reacted-emoji downvote" src="<%= asset_path("emoji/emoji-one-thumbs-down.png") %>" alt="Thumbs down emoji" />
|
||||
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
|
||||
</div>
|
||||
<span class="vote-text">Low Quality</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="reaction-vomit-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "vomit") ? "reacted" : "" %>"
|
||||
data-reactable-id="<%= @moderatable.id %>"
|
||||
data-category="vomit"
|
||||
data-reactable-type="<%= @moderatable.class.name %>">
|
||||
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji", class: "emoji-grey") %>
|
||||
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
|
||||
<span>Flag to Admins</span>
|
||||
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon vomit-checkmark", title: "Checkmark") %>
|
||||
</button>
|
||||
<a href="<%= URL.url %>/community-moderation#mod-voting" target="_blank" rel="noopener">
|
||||
<span class="how-this-works-section">
|
||||
How does this work?
|
||||
<%= inline_svg_tag("external-link-logo.svg", aria: true) %>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="other-things-container">
|
||||
<header class="other-things">
|
||||
<h1>Other things you can do</h1>
|
||||
</header>
|
||||
<% if current_user.any_admin? || @should_show_adjust_tags %>
|
||||
<button class="other-things-btn adjust-tags" type="button" data-other-things-type="adjust-tags">
|
||||
<div class="label-wrapper">
|
||||
<div class="icon circle centered-icon">
|
||||
<%= inline_svg_tag("mod-filled.svg", aria: true, title: "Adjust tags") %>
|
||||
</div>
|
||||
<header>
|
||||
<h2>Adjust tags</h2>
|
||||
<h3>Add or remove tags</h3>
|
||||
</header>
|
||||
</div>
|
||||
<div class="toggle-chevron-container">
|
||||
<%= inline_svg_tag("chevron-toggle.svg", aria: false) %>
|
||||
</div>
|
||||
</button>
|
||||
<div class="adjust-tags-options dropdown-options hidden">
|
||||
<% if current_user.any_admin? %>
|
||||
<% if @moderatable.tag_list.size < 4 %>
|
||||
<div class="add-tag-container">
|
||||
<input id="admin-add-tag" class="crayons-textfield" type="text" placeholder="Add a tag" data-article-id="<%= @moderatable.id %>" data-adjustment-type="plus">
|
||||
</div>
|
||||
<% end %>
|
||||
<% @moderatable.tags.each do |tag| %>
|
||||
<button class="adjustable-tag" type="button" data-adjustment-type="subtract" data-tag-name="<%= tag.name %>" data-article-id="<%= @moderatable.id %>">
|
||||
<span class="num-sign">#</span><%= tag.name %>
|
||||
<div class="circle centered-icon adjustment-icon subtract">
|
||||
<%= inline_svg_tag("subtract.svg", aria: false) %>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% @tag_moderator_tags.each do |tag| %>
|
||||
<% subtract_or_plus = @moderatable.tag_list.include?(tag.name) ? "subtract" : "plus" %>
|
||||
<button class="adjustable-tag" type="button" data-adjustment-type="<%= subtract_or_plus %>" data-tag-name="<%= tag.name %>" data-article-id="<%= @moderatable.id %>">
|
||||
<span class="num-sign">#</span><%= tag.name %>
|
||||
<div class="circle centered-icon adjustment-icon <%= subtract_or_plus %>">
|
||||
<%= inline_svg_tag("#{subtract_or_plus}.svg", aria: false) %>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<form id="adjustment-reason-container" class="adjustment-reason-container hidden">
|
||||
<textarea class="crayons-textfield" placeholder="Reason for tag adjustment" id="tag-adjustment-reason" required></textarea>
|
||||
<button class="crayons-btn" id="tag-adjust-submit" type="submit">Submit</button>
|
||||
</form>
|
||||
<a href="<%= URL.url %>/tag-moderation#tag-adjustments" target="_blank" rel="noopener">
|
||||
<span class="how-this-works-section">
|
||||
How does this work?
|
||||
<%= inline_svg_tag("external-link-logo.svg", aria: true) %>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<% @rating_vote = RatingVote.where(article_id: @moderatable.id, user_id: current_user.id).first %>
|
||||
<button class="other-things-btn set-experience" type="button" data-other-things-type="set-experience">
|
||||
<div class="label-wrapper">
|
||||
<div class="icon circle centered-icon">
|
||||
<%= inline_svg_tag("book.svg", aria: false) %>
|
||||
</div>
|
||||
<header>
|
||||
<h2>Set experience level</h2>
|
||||
<h3>Curate the most appropriate audience for this post</h3>
|
||||
</header>
|
||||
</div>
|
||||
<div class="toggle-chevron-container">
|
||||
<%= inline_svg_tag("chevron-toggle.svg", aria: false) %>
|
||||
</div>
|
||||
</button>
|
||||
<div class="set-experience-options dropdown-options hidden">
|
||||
<% rating_hash = { "Expert" => [10, 5], "Advanced" => [8, 4], "Mid-level" => [5, 3], "Beginner" => [3, 2], "Novice" => [1, 1] } %>
|
||||
<% rating_hash.each do |rating_name, rating_level| %>
|
||||
<button
|
||||
value="<%= rating_level[0] %>"
|
||||
name="rating_vote_<%= rating_level[0] %>"
|
||||
id="js__rating__vote__<%= rating_level[0] %>"
|
||||
class="level-rating-button <%= " selected" if @rating_vote&.rating == rating_level[0].to_f %>"
|
||||
data-user-id="<%= current_user.id %>"
|
||||
data-article-id="<%= @moderatable.id %>"
|
||||
data-group="experience_level">
|
||||
<div class="level-rating-content">
|
||||
<span class="level-rating-number"><%= rating_level[1] %></span>
|
||||
<span class="level-rating-text"><%= rating_name %></span>
|
||||
</div>
|
||||
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon", title: "Checkmark") %>
|
||||
</button>
|
||||
<% end %>
|
||||
<a href="<%= URL.url %>/community-moderation#setting-experience-level" target="_blank" rel="noopener">
|
||||
<span class="how-this-works-section">
|
||||
How does this work?
|
||||
<%= inline_svg_tag("external-link-logo.svg", aria: true) %>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-actions">
|
||||
<button id="open-flag-user-modal" type="button">Flag user</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,10 +78,11 @@
|
|||
els[i].onclick = function(e) {
|
||||
let path = e.target.dataset.path
|
||||
let el = document.getElementById('quick-mod-' + i)
|
||||
// if iframe is opened/loaded, clear the innerHTML
|
||||
if (el.innerHTML.length > 10) {
|
||||
el.innerHTML = ''
|
||||
} else {
|
||||
el.innerHTML = '<div class="mod-index-row-iframes"><iframe src="' + path + '"></iframe><iframe src="' + path + '/mod"></iframe></div>'
|
||||
el.innerHTML = '<div class="mod-index-row-iframes"><iframe id="quick-mod-article" src="' + path + '"></iframe><iframe class="actions-panel" src="' + path + '/actions_panel"></iframe></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@
|
|||
<h2><a href="<%= @moderatable.path %>" rel="nofollow"><%= @moderatable.title %></a></h2>
|
||||
<h3><a href="<%= @moderatable.user.path %>">@<%= @moderatable.user.username %></a></h3>
|
||||
<% if @moderatable.class.name == "Article" &&
|
||||
(Tag.where(requires_approval: true).pluck(:name) & @moderatable.decorate.cached_tag_list_array).any? &&
|
||||
(current_user.any_admin? || @tag_moderator_tags.any? { |tag| @moderatable.tag_list.include?(tag.name) })
|
||||
%>
|
||||
(Tag.where(requires_approval: true).pluck(:name) & @moderatable.decorate.cached_tag_list_array).any? &&
|
||||
(current_user.any_admin? || @tag_moderator_tags.any? { |tag| @moderatable.tag_list.include?(tag.name) }) %>
|
||||
<%= form_tag "/article_approvals", method: "post" do %>
|
||||
<%= hidden_field_tag(:id, @moderatable.id) %>
|
||||
<% if @moderatable.approved %>
|
||||
|
|
@ -26,20 +25,42 @@
|
|||
<hr />
|
||||
<% end %>
|
||||
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsdown") ? "reacted" : "" %>"
|
||||
<div style="display: flex; justify-content: space-around;">
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsup") ? "reacted" : "" %>"
|
||||
data-reactable-id="<%= @moderatable.id %>"
|
||||
data-category="thumbsdown"
|
||||
data-category="thumbsup"
|
||||
data-reactable-type="<%= @moderatable.class.name %>">
|
||||
<%= image_tag("emoji/emoji-one-thumbs-down-gray.png", alt: "Thumbs down emoji") %>
|
||||
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-thumbs-down.png") %>" alt="Thumbs down emoji" />
|
||||
</button>
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "vomit") ? "reacted" : "" %>"
|
||||
data-reactable-id="<%= @moderatable.id %>"
|
||||
data-category="vomit"
|
||||
data-reactable-type="<%= @moderatable.class.name %>">
|
||||
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji") %>
|
||||
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
|
||||
<div class="reaction-button-circle">
|
||||
<%= image_tag("emoji/emoji-one-thumbs-up-gray.png", alt: "Thumbs up emoji", class: "emoji-grey") %>
|
||||
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-thumbs-up.png") %>" alt="Thumbs up emoji" />
|
||||
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
|
||||
</div>
|
||||
<span class="vote-text">High Quality</span>
|
||||
</button>
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsdown") ? "reacted" : "" %>"
|
||||
data-reactable-id="<%= @moderatable.id %>"
|
||||
data-category="thumbsdown"
|
||||
data-reactable-type="<%= @moderatable.class.name %>">
|
||||
<div class="reaction-button-circle">
|
||||
<%= image_tag("emoji/emoji-one-thumbs-down-gray.png", alt: "Thumbs down emoji", class: "emoji-grey") %>
|
||||
<img class="reacted-emoji downvote" src="<%= asset_path("emoji/emoji-one-thumbs-down.png") %>" alt="Thumbs down emoji" />
|
||||
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
|
||||
</div>
|
||||
<span class="vote-text">Low Quality</span>
|
||||
</button>
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "vomit") ? "reacted" : "" %>"
|
||||
data-reactable-id="<%= @moderatable.id %>"
|
||||
data-category="vomit"
|
||||
data-reactable-type="<%= @moderatable.class.name %>">
|
||||
<div class="reaction-button-circle">
|
||||
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji", class: "emoji-grey") %>
|
||||
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
|
||||
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
|
||||
</div>
|
||||
<span class="vote-text">Mark As Spam</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<% if current_user.has_role?(:super_admin) && @moderatable.class.name == "Article" %>
|
||||
<h3> <a href="<%= @moderatable.path %>/edit">Edit Post</a> |
|
||||
<a href="/internal/articles/<%= @moderatable.id %>" data-no-instant>Internal:Article</a> |
|
||||
|
|
@ -49,22 +70,27 @@
|
|||
<a href="/admin/users/<%= @moderatable.user_id %>" data-no-instant>Admin:User</a></h3>
|
||||
<% end %>
|
||||
<p>
|
||||
<b style="font-size:1.3em">All negative reactions are private.</b>
|
||||
<b style="font-size:1em">All negative reactions are private.</b>
|
||||
</p>
|
||||
<p>
|
||||
Use <b>thumbsdown</b> to move this content "down" for any reason (quality, usefulness, etc.).
|
||||
Use <b>High Quality</b> to move this content "up" for any reason (quality, usefulness, etc.).
|
||||
<br />
|
||||
Use <b>Low Quality</b> to move this content "down" for any reason (quality, usefulness, etc.).
|
||||
<br />
|
||||
Use <b>Mark As Spam</b> for code of conduct violations (harassment, being a jerk, spam, etc.).
|
||||
</p>
|
||||
<p>
|
||||
Use <b>vomit</b> for code of conduct violations (harassment, being a jerk, spam, etc.).
|
||||
</p>
|
||||
<div class="tag-mod-form">
|
||||
<div class="tag-mod-form" style="display: flex; flex-direction: column; align-items: center;">
|
||||
<h2>Flag <a href="<%= @moderatable.user.path %>"><%= @moderatable.user.username %></a> as Abusive</h2>
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable.user, current_user, "vomit") ? "reacted" : "" %>"
|
||||
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "vomit") ? "reacted" : "" %>"
|
||||
data-reactable-id="<%= @moderatable.user.id %>"
|
||||
data-category="vomit"
|
||||
data-reactable-type="<%= @moderatable.user.class.name %>">
|
||||
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji") %>
|
||||
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
|
||||
<div class="reaction-button-circle">
|
||||
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji", class: "emoji-grey") %>
|
||||
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
|
||||
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
|
||||
</div>
|
||||
<span class="vote-text">Flag As Abusive</span>
|
||||
</button>
|
||||
<p>
|
||||
This is the equivalent of vomiting on <b>all</b> of this user's articles.
|
||||
|
|
@ -136,20 +162,25 @@
|
|||
<% if @moderatable.class.name == "Article" %>
|
||||
<div class="tag-mod-form">
|
||||
<% @rating_vote = RatingVote.where(article_id: @moderatable.id, user_id: current_user.id).first %>
|
||||
<%= form_for(RatingVote.new) do |f| %>
|
||||
<h2>Experience Level of Post</h2>
|
||||
<%= f.hidden_field :article_id, value: @moderatable.id %>
|
||||
<%= f.hidden_field :group, value: "experience_level" %>
|
||||
<button value="1" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 1.0 %>">1</button>
|
||||
<button value="2" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 2.0 %>">2</button>
|
||||
<button value="3" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 3.0 %>">3</button>
|
||||
<button value="4" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 4.0 %>">4</button>
|
||||
<button value="5" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 5.0 %>">5</button>
|
||||
<button value="6" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 6.0 %>">6</button>
|
||||
<button value="7" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 7.0 %>">7</button>
|
||||
<button value="8" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 8.0 %>">8</button>
|
||||
<button value="9" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 9.0 %>">9</button>
|
||||
<button value="10" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 10.0 %>">10</button>
|
||||
<% rating_hash = { "Novice" => 1, "Beginner" => 2, "Mid-level" => 3, "Advanced" => 4, "Expert" => 5 } %>
|
||||
|
||||
<h2>Experience Level of Post</h2>
|
||||
|
||||
<% rating_hash.each do |rating_name, rating_level| %>
|
||||
<button
|
||||
value="<%= rating_level %>"
|
||||
name="rating_vote_<%= rating_level %>"
|
||||
id="js__rating__vote__<%= rating_level %>"
|
||||
class="level-rating-button article-mod-page <%= "selected" if @rating_vote&.rating == rating_level.to_f %>"
|
||||
data-user-id="<%= current_user.id %>"
|
||||
data-article-id="<%= @moderatable.id %>"
|
||||
data-group="experience_level"
|
||||
style="margin: 0.5rem 0;">
|
||||
<div class="level-rating-content">
|
||||
<span class="level-rating-number"><%= rating_level %> - </span>
|
||||
<span class="level-rating-text"><%= rating_name %></span>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
<p>
|
||||
Who <em>might</em> find this post most valuable, based on overall experience level?
|
||||
|
|
@ -170,12 +201,25 @@
|
|||
|
||||
<script defer>
|
||||
setTimeout(function () {
|
||||
var butts = document.getElementsByClassName('reaction-button');
|
||||
var butts = document.querySelectorAll('.reaction-button, .reaction-vomit-button');
|
||||
function applyReactedClass(category) {
|
||||
const upVote = document.querySelector("[data-category='thumbsup']");
|
||||
const downVote = document.querySelector("[data-category='thumbsdown']");
|
||||
const vomitVote = document.querySelector("[data-category='vomit']");
|
||||
|
||||
if (category === 'thumbsup') {
|
||||
downVote.classList.remove('reacted');
|
||||
vomitVote.classList.remove('reacted');
|
||||
} else {
|
||||
upVote.classList.remove('reacted');
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < butts.length; i++) {
|
||||
var butt = butts[i];
|
||||
butt.onclick = function (event) {
|
||||
event.preventDefault();
|
||||
var thisButt = this;
|
||||
applyReactedClass(thisButt.dataset.category);
|
||||
thisButt.classList.add('reacted');
|
||||
|
||||
function successCb(response) {
|
||||
|
|
@ -200,5 +244,59 @@
|
|||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Experience-Level JS
|
||||
function clearExpLevels() {
|
||||
Array.from(
|
||||
document.getElementsByClassName('level-rating-button selected'),
|
||||
).forEach((el) => {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
}
|
||||
|
||||
async function updateExperienceLevel(currentUserId, articleId, rating, group) {
|
||||
try {
|
||||
const response = await fetch('/rating_votes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
user_id: currentUserId,
|
||||
article_id: articleId,
|
||||
rating,
|
||||
group,
|
||||
}),
|
||||
});
|
||||
|
||||
const outcome = await response.json();
|
||||
|
||||
if (outcome.result === 'Success') {
|
||||
clearExpLevels();
|
||||
document
|
||||
.getElementById(`js__rating__vote__${rating}`)
|
||||
.classList.add('selected');
|
||||
} else {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(outcome.error);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.level-rating-button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
updateExperienceLevel(
|
||||
btn.dataset.userId,
|
||||
btn.dataset.articleId,
|
||||
btn.value,
|
||||
btn.dataset.group,
|
||||
);
|
||||
});
|
||||
});
|
||||
}, 200)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ Rails.application.routes.draw do
|
|||
resources :pages, only: %i[index new create edit update destroy]
|
||||
resources :mods, only: %i[index update]
|
||||
resources :moderator_actions, only: %i[index]
|
||||
resources :negative_reactions, only: %i[index]
|
||||
resources :privileged_reactions, only: %i[index]
|
||||
resources :permissions, only: %i[index]
|
||||
resources :podcasts, only: %i[index edit update destroy] do
|
||||
member do
|
||||
|
|
@ -450,6 +450,7 @@ Rails.application.routes.draw do
|
|||
get "/:username/:slug/:view" => "stories#show",
|
||||
:constraints => { view: /moderate/ }
|
||||
get "/:username/:slug/mod" => "moderations#article"
|
||||
get "/:username/:slug/actions_panel" => "moderations#actions_panel"
|
||||
get "/:username/:slug/manage" => "articles#manage"
|
||||
get "/:username/:slug/edit" => "articles#edit"
|
||||
get "/:username/:slug/delete_confirm" => "articles#delete_confirm"
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ environment.splitChunks((config) => {
|
|||
__dirname,
|
||||
'../../app/javascript/crayons',
|
||||
) /* global __dirname */,
|
||||
'@utilities': path.resolve(__dirname, '../../app/javascript/utlities'),
|
||||
'@utilities': path.resolve(__dirname, '../../app/javascript/utilities'),
|
||||
},
|
||||
},
|
||||
optimization: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# This file is auto-generated from the current state of the database. Instead
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# incrementally modify your database, and then regenerate this schema definition.
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ title: Tracking Metrics
|
|||
We track point-in-time data by sending data points to Datadog using
|
||||
[Statdsd](https://github.com/DataDog/dogstatsd-ruby).
|
||||
|
||||
Currently, we run a daily metric fetching job in `fetch.rake` which is called via
|
||||
the Heroku scheduler. If we wanted to do other frequencies like hourly, etc. we
|
||||
could add similar tasks.
|
||||
Currently, we run a daily metric fetching job in `fetch.rake` which is called
|
||||
via the Heroku scheduler. If we wanted to do other frequencies like hourly, etc.
|
||||
we could add similar tasks.
|
||||
|
||||
The rake task calls `Metrics::RecordDailyUsageWorker.perform_async`, which
|
||||
performs algorithms to come up with data points to send, for example:
|
||||
|
|
@ -22,8 +22,8 @@ If you want to create a new periodic data send, follow this pattern to do so.
|
|||
|
||||
## Vendor-Agnostic
|
||||
|
||||
While we currently are not vendor-agnostic in how we do this (Heroku/Datadog), it
|
||||
is set up in a way that could become so in the future. The main pattern is
|
||||
While we currently are not vendor-agnostic in how we do this (Heroku/Datadog),
|
||||
it is set up in a way that could become so in the future. The main pattern is
|
||||
`Every x minutes/hours/etc. send aggregate data to warehouse where it can be examined on a timeseries basis`.
|
||||
This could, in the future, be bundled right into the platform using an open
|
||||
source timeseries database and data visualization.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@crayons/*": ["app/javascript/crayons/*"],
|
||||
"@utilities/*": ["app/javascript/utlities/*"]
|
||||
"@utilities/*": ["app/javascript/utilities/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "**/node_modules/*"]
|
||||
|
|
|
|||
|
|
@ -38,4 +38,16 @@ RSpec.describe UserPolicy, type: :policy do
|
|||
|
||||
it { is_expected.to forbid_actions(%i[moderation_routes]) }
|
||||
end
|
||||
|
||||
context "when the user is an admin" do
|
||||
let(:user) { build(:user, :admin) }
|
||||
|
||||
it { is_expected.to permit_actions(%i[moderation_routes]) }
|
||||
end
|
||||
|
||||
context "when the user is a super admin" do
|
||||
let(:user) { build(:user, :super_admin) }
|
||||
|
||||
it { is_expected.to permit_actions(%i[moderation_routes]) }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe "/internal/negative_reactions", type: :request do
|
||||
RSpec.describe "/internal/privileged_reactions", type: :request do
|
||||
context "when the user is not an admin" do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ RSpec.describe "/internal/negative_reactions", type: :request do
|
|||
|
||||
it "blocks the request" do
|
||||
expect do
|
||||
get "/internal/negative_reactions"
|
||||
get "/internal/privileged_reactions"
|
||||
end.to raise_error(Pundit::NotAuthorizedError)
|
||||
end
|
||||
end
|
||||
|
|
@ -38,13 +38,13 @@ RSpec.describe "/internal/negative_reactions", type: :request do
|
|||
|
||||
it "does not block the request" do
|
||||
expect do
|
||||
get "/internal/negative_reactions"
|
||||
get "/internal/privileged_reactions"
|
||||
end.not_to raise_error
|
||||
end
|
||||
|
||||
describe "GETS /internal/negative_reactions" do
|
||||
describe "GETS /internal/privileged_reactions" do
|
||||
it "renders to appropriate page" do
|
||||
get "/internal/negative_reactions"
|
||||
get "/internal/privileged_reactions"
|
||||
expect(response.body).to include(CGI.escapeHTML(moderator.username)).
|
||||
and include(CGI.escapeHTML(user_reaction.reactable.username)).
|
||||
and include(CGI.escapeHTML(comment_reaction.reactable.user.username)).
|
||||
|
|
@ -2,7 +2,7 @@ require "rails_helper"
|
|||
|
||||
RSpec.shared_examples "an elevated privilege required request" do |path|
|
||||
context "when not logged-in" do
|
||||
it "does not grant acesss", proper_status: true do
|
||||
it "does not grant access", proper_status: true do
|
||||
get path
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
|
@ -27,17 +27,18 @@ RSpec.shared_examples "an elevated privilege required request" do |path|
|
|||
end
|
||||
|
||||
RSpec.describe "Moderations", type: :request do
|
||||
let(:user) { create(:user, :trusted) }
|
||||
let(:trusted_user) { create(:user, :trusted) }
|
||||
let(:article) { create(:article) }
|
||||
let(:comment) { create(:comment, commentable: article) }
|
||||
let(:dev_account) { create(:user) }
|
||||
|
||||
it_behaves_like "an elevated privilege required request", "/username/random-article/mod"
|
||||
it_behaves_like "an elevated privilege required request", "/username/comment/1/mod"
|
||||
it_behaves_like "an elevated privilege required request", "/username/random-article/actions_panel"
|
||||
|
||||
context "when user is trusted" do
|
||||
before do
|
||||
sign_in user
|
||||
sign_in trusted_user
|
||||
allow(User).to receive(:dev_account).and_return(dev_account)
|
||||
end
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ RSpec.describe "Moderations", type: :request do
|
|||
end
|
||||
|
||||
it "grants access to /mod index" do
|
||||
create(:rating_vote, article: article, user: user)
|
||||
create(:rating_vote, article: article, user: trusted_user)
|
||||
get "/mod"
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
|
@ -69,12 +70,69 @@ RSpec.describe "Moderations", type: :request do
|
|||
expect(response.body).to include("#" + article.tags.first.name.titleize)
|
||||
end
|
||||
|
||||
it "returns not found for inapprpriate tags" do
|
||||
it "returns not found for inappropriate tags" do
|
||||
expect { get "/mod/dsdsdsweweedsdseweww" }.to raise_exception(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it "renders not_found when an article can't be found" do
|
||||
expect { get "/#{user.username}/dsdsdsweweedsdseweww/mod/" }.to raise_exception(ActiveRecord::RecordNotFound)
|
||||
expect { get "/#{trusted_user.username}/dsdsdsweweedsdseweww/mod/" }.to raise_exception(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
describe "actions_panel" do
|
||||
context "when the user is a tag moderator" do
|
||||
it "shows the option to remove the tag when the article has the tag" do
|
||||
tag_mod = create(:user, :tag_moderator)
|
||||
tag_mod.add_role :trusted
|
||||
tag = tag_mod.roles.find_by(name: "tag_moderator").resource
|
||||
article = create(:article, tags: tag)
|
||||
sign_in tag_mod
|
||||
|
||||
get "#{article.path}/actions_panel"
|
||||
expect(response.body).to include "circle centered-icon adjustment-icon subtract"
|
||||
end
|
||||
end
|
||||
|
||||
it "shows the option to add the tag when the article has the tag" do
|
||||
tag_mod = create(:user, :tag_moderator)
|
||||
tag_mod.add_role :trusted
|
||||
article = create(:article, tags: "javascript, cool, beans")
|
||||
sign_in tag_mod
|
||||
|
||||
get "#{article.path}/actions_panel"
|
||||
expect(response.body).to include "circle centered-icon adjustment-icon plus"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the user is trusted" do
|
||||
before do
|
||||
sign_in trusted_user
|
||||
get "#{article.path}/actions_panel"
|
||||
end
|
||||
|
||||
it "does not show the adjust tags options" do
|
||||
expect(response.body).not_to include "other-things-btn adjust-tags"
|
||||
end
|
||||
|
||||
it "shows the experience level option" do
|
||||
expect(response.body).to include "other-things-btn set-experience"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the user is an admin" do
|
||||
before do
|
||||
admin = create(:user, :admin)
|
||||
sign_in admin
|
||||
article = create(:article, tags: "javascript, cool, beans")
|
||||
get "#{article.path}/actions_panel"
|
||||
end
|
||||
|
||||
it "shows the admin add tag option if the article has room for a tag" do
|
||||
expect(response.body).to include "admin-add-tag"
|
||||
end
|
||||
|
||||
it "shows the option to remove tags" do
|
||||
expect(response.body).to include "circle centered-icon adjustment-icon subtract"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||