Tag moderation UI changes (#19906)

* Basic add remove functionality with few tests

* Updated cypress test and added more unit tests

* Rename display ads vars in the views (#19877)

* Renamed display_ads vars to billboards

* Rename local vars in the feed view

* Renamed display-ad in the comment

Co-authored-by: Rajat Talesra <rajattalesra4914@gmail.com>

* Renamed variables in the views

---------

Co-authored-by: Rajat Talesra <rajattalesra4914@gmail.com>

* Update js-routes to version 2.2.7 (#19883)

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>

* add field for billboard target geolocations, with unit and e2e tests (#19855)

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
Co-authored-by: Duke Greene <dukegreene@gmail.com>
Co-authored-by: PJ <pj@forem.com>
Co-authored-by: Anna Buianova <lightallloy@gmail.com>
Co-authored-by: Mac Siri <mac@forem.com>
Co-authored-by: zhangted <tedcbook@gmail.com>
Co-authored-by: Josh Klar <jklar@forem.com>

* Added autocomplete value off (#19882)

* Bust cache of organization page when new member is added (#19861)

* Remove unused freezeScrolling (#19808)

* Remove unused freezeScrolling

* chore: try see if this helps to skip the flakey test that wasn't being skipped

* chore: try see if this helps to skip the flakey test that wasn't being skipped

* oops: revert the gemfile changes

---------

Co-authored-by: Mai Irie <mai@forem.com>
Co-authored-by: Mac Siri <mac@forem.com>
Co-authored-by: Ridhwana <ridhwana.khan16@gmail.com>

* ci: Allow workflow_dispatch runs to log in to GHCR. (#19885)

* ci: Fix base image builder... the right way... (#19889)

* Update postcss to version 8.4.27 (#19888)

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>

* Replace uses of toBeDefined with toExist  (#19862)

* Replaced toBeDefined with toExist

* Revert db changes

* Rename display ads to billboards: css and js code (#19887)

* Renamed js vars/css names

* Renamed display_ads to billboards in js functions, variables, components

* [ruby] Update sidekiq-unique-jobs 7.1.29 → 7.1.30 (patch) (#19902)

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>

* Update oj to version 3.15.1 (#19901)

Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>

* Updated failing cypress test

* Removed commented code

* Attempt at fixing tests

* Fixed failing tests

* Added more tests

* Basic color and design changes part -1

* Disable/Enable buttons correctly

* Various design updates in Tag adjustement mod UI

* I18N and documentation

* Updated mod panel

* Fixed mod related cypress tests

* Minor design fixes

* Dark mode designs

* Test update

* Mod UI with some bugs

* Updated/simplified code fully-working

* Updated tests

* Added more tests

* Accessibility improvements

* hr line code fix

* Close mod icon bug fix

* Nit fixes

* Design fixes as suggested by Anuj.

* Design fixes

* Dark mode file usage

* Design fixes

* Nit fixes

---------

Co-authored-by: Anna Buianova <lightallloy@gmail.com>
Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com>
Co-authored-by: Duke Greene <dukegreene@gmail.com>
Co-authored-by: PJ <pj@forem.com>
Co-authored-by: Mac Siri <mac@forem.com>
Co-authored-by: zhangted <tedcbook@gmail.com>
Co-authored-by: Josh Klar <jklar@forem.com>
Co-authored-by: Joshua Wehner <joshua@forem.com>
Co-authored-by: Mai Irie <mai@forem.com>
Co-authored-by: Ridhwana <ridhwana.khan16@gmail.com>
This commit is contained in:
Rajat Talesra 2023-08-24 00:31:21 +05:30 committed by GitHub
parent 85b0a18880
commit 992ced1908
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 777 additions and 222 deletions

View file

@ -225,6 +225,7 @@ Lint/MissingSuper:
Enabled: true
Exclude:
- 'app/components/**/*.rb'
- 'app/policies/**/*.rb'
Lint/MixedRegexpCaptureTypes:
Description: 'Do not mix named captures and numbered captures in a Regexp literal.'

View file

@ -366,6 +366,30 @@
--banner-color: var(--white);
--banner-accent: var(--accent-brand);
/***********************************************
** Tag Moderation ******************************
***********************************************/
/* Tag item */
--tag-item-bg-hover: rgb(var(--indigo-50));
/* Subtract */
--subtract-color: rgb(var(--red-600));
--subtract-color-hover: rgb(var(--white));
--subtract-bg: rgba(var(--red-600), 0.2);
--subtract-bg-hover: rgba(var(--red-600));
/* Plus */
--plus-color: rgb(var(--green-800));
--plus-color-hover: rgb(var(--white));
--plus-bg: rgb(var(--green-100));
--plus-bg-hover: rgb(var(--green-600));
/* Tag name */
--tag-hash: rgb(var(--grey-500));
--tag-name: rgb(var(--grey-800));
--add-tag-hash: rgb(var(--grey-700));
/***********************************************
** Utilities ***********************************
***********************************************/

View file

@ -1,6 +1,6 @@
:root {
color-scheme: dark;
--theme-secondary-color: #cedae2;
--theme-container-background: #141f2d;
--theme-container-accent-background: #202c3d;
@ -353,6 +353,30 @@
--selected-date-color: var(--body-color);
--selected-range-color: var(--body-color-inverted);
/***********************************************
** Tag Moderation ******************************
***********************************************/
/* Tag item */
--tag-item-bg-hover: rgba(var(--indigo-900), 0.5);
/* Subtract */
--subtract-color: rgb(var(--red-200));
--subtract-color-hover: rgb(var(--white));
--subtract-bg: rgba(var(--red-600), 0.4);
--subtract-bg-hover: rgb(var(--red-600));
/* Plus */
--plus-color: rgb(var(--green-200));
--plus-color-hover: rgb(var(--white));
--plus-bg: rgba(var(--green-600), 0.4);
--plus-bg-hover: rgb(var(--green-600));
/* Tag name */
--tag-hash: rgb(var(--grey-500));
--tag-name: rgb(var(--grey-50));
--add-tag-hash: rgb(var(--grey-200));
/***********************************************
** Utilities ***********************************
***********************************************/

View file

@ -31,9 +31,18 @@
}
.mod-container {
hr {
width: 100% !important;
max-width: none !important;
height: 1px;
margin: 8px 0px 8px 0px !important;
padding: 0px;
background-color: var(--grey-200);
}
display: flex;
flex-direction: column;
background-color: var(--base-0);
background-color: var(--base-inverted);
border: none;
box-shadow: none;
margin-bottom: 0;
@ -201,6 +210,20 @@
}
}
.max-tags-text {
display: flex;
padding: var(--su-2) var(--su-3);
align-items: center;
gap: var(--su-2);
span {
color: var(--color-secondary);
font-size: var(--fs-s);
line-height: var(--lh-base);
letter-spacing: -0.28px;
}
}
.additional-subtext-section {
font-weight: var(--fw-medium);
font-size: var(--fs-xs);
@ -375,49 +398,133 @@
.add-tag-container {
padding: var(--su-2) var(--su-1);
}
.add-tag-header {
color: var(--tag-name);
font-size: var(--fs-base);
font-weight: var(--fw-bold);
line-height: var(--lh-base);
}
.tag-input-container {
display: flex;
margin-top: 12px;
padding: 8px 8px 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
border-radius: 6px;
border-width: 1px;
border-style: solid;
border-color: var(--grey-500);
span {
font-weight: var(--fw-medium);
font-size: var(--fs-xl);
color: var(--add-tag-hash);
}
input {
border: none;
outline: none;
background-color: transparent;
color: var(--grey-500);
font-size: var(--fs-base);
line-height: var(--ls-base);
}
.input:active,
.input:focus {
border: none;
outline: none;
box-shadow: none;
}
}
button.adjustable-tag {
background: inherit;
background-color: inherit;
border: none;
padding: var(--su-2) 2px;
padding: var(--su-2) var(--su-3);
font-family: var(--ff-monospace);
font-size: var(--fs-base);
text-align: left;
color: var(--base-80);
font-family: var(--ff-monospace);
.num-sign {
color: var(--base-40);
padding-left: 2px;
line-height: var(--lh-base);
border-radius: var(--radius);
letter-spacing: -0.32px;
color: var(--tag-name);
.js-focus-visible &.focus-visible:focus,
&:is(label):focus-within {
z-index: var(--z-elevate);
}
.adjustment-icon {
.js-focus-visible &.focus-visible:focus,
&:is(label):focus-within {
box-shadow: var(--focus-ring);
outline: 0;
}
.num-sign {
color: var(--tag-hash);
padding-left: 2px;
padding-right: 2px;
}
.remove-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);
}
background-color: var(--subtract-bg);
color: var(--subtract-color);
}
&:hover {
background: var(--base-10);
border-radius: 8px;
background-color: var(--tag-item-bg-hover);
border-radius: 6px;
.remove-icon {
opacity: 1;
background-color: var(--subtract-bg-hover);
color: var(--subtract-color-hover);
}
}
&.active {
background: var(--base-10);
border-radius: 8px;
}
// &.selected {}
}
.adjustment-reason-container {
button.add-tag {
background-color: inherit;
border: none;
padding: var(--su-2) var(--su-3);
font-size: var(--fs-base);
text-align: left;
color: var(--base-80);
border-radius: var(--radius);
font-family: var(--ff-monospace);
.add-icon {
width: 24px;
height: 24px;
float: right;
margin-right: 2px;
background: var(--plus-bg);
color: var(--plus-color);
}
&:hover {
background-color: var(--tag-item-bg-hover);
border-radius: 6px;
.add-icon {
background-color: var(--plus-bg-hover);
color: var(--plus-color-hover);
}
}
.js-focus-visible &.focus-visible:focus,
&:is(label):focus-within {
z-index: var(--z-elevate);
}
.js-focus-visible &.focus-visible:focus,
&:is(label):focus-within {
box-shadow: var(--focus-ring);
outline: 0;
}
}
.reason-container {
display: flex;
flex-direction: column;
padding: var(--su-1);
padding: var(--su-1) var(--su-3) var(--su-2) var(--su-3);
textarea {
margin-bottom: var(--su-2);
margin-bottom: var(--su-3);
}
}
}

View file

@ -1,12 +1,19 @@
/* eslint-disable no-restricted-globals */
import fetch from 'jest-fetch-mock';
import { fireEvent } from '@testing-library/preact';
import {
addCloseListener,
initializeHeight,
addReactionButtonListeners,
addAdjustTagListeners,
handleAddTagButtonListeners,
handleAddModTagButtonsListeners,
handleRemoveTagButtonsListeners,
} from '../actionsPanel';
global.fetch = fetch;
global.top.addSnackbarItem = jest.fn();
global.alert = jest.fn();
describe('addCloseListener()', () => {
test('toggles the mod actions panel and its button on click', () => {
document.body.innerHTML = `
@ -152,72 +159,288 @@ describe('addReactionButtonListeners()', () => {
});
describe('addAdjustTagListeners()', () => {
describe('when the user is tag moderator of #discuss', () => {
describe('when an article has no tags', () => {
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">
<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>
`;
fetch.resetMocks();
addAdjustTagListeners();
document.body.innerHTML = `
<button id="add-tag-button" class="add-tag" type="button">
Add new tag
</button>
<div id="add-tag-container" class="hidden">
<div id="add-reason-container" class="reason-container">
<input id="admin-add-tag" class="crayons-textfield" type="text" autocomplete="off" placeholder="Add a tag" data-article-id="32" data-adjustment-type="plus">
<textarea class="crayons-textfield" placeholder="Reason to add tag (optional)" id="tag-add-reason"></textarea>
<div class="flex gap-3">
<button class="w-100 c-btn c-btn--primary" disabled="disabled" id="tag-add-submit">Submit</button>
<button class="w-100 c-btn" id="cancel-add-tag-button" type="button">Cancel</button>
</div>
</div>
</div>
`;
handleAddTagButtonListeners();
});
describe('when an article is tagged with #discuss', () => {
it('toggles the tag button and the form', () => {
const tagBtn = document.getElementsByClassName('adjustable-tag')[0];
tagBtn.click();
expect(tagBtn.classList).toContain('active');
function tagResponse() {
return JSON.stringify({
status: 'Success',
result: 'addition',
colors: {
bg: '#8c5595',
text: '#39ad55',
},
});
}
it('shows the add tag button', () => {
expect(document.getElementById('add-tag-button').classList).not.toContain(
'hidden',
);
expect(document.getElementById('add-tag-container').classList).toContain(
'hidden',
);
});
it('click on add tag button shows the container and hides the button', () => {
document.getElementById('add-tag-button').click();
expect(document.getElementById('add-tag-button').classList).toContain(
'hidden',
);
expect(
document.getElementById('add-tag-container').classList,
).not.toContain('hidden');
});
it('by default the add tag submit button is disabled and gets enabled when entered tag', () => {
document.getElementById('add-tag-button').click();
expect(
document.getElementById('tag-add-submit').hasAttribute('disabled'),
).toBeTruthy();
fireEvent.input(document.getElementById('admin-add-tag'), {
target: { value: 'New Tag' },
});
expect(
document.getElementById('tag-add-submit').hasAttribute('disabled'),
).toBeFalsy();
});
it('click on cancel button hides the container and shows add-tag button', () => {
document.getElementById('add-tag-button').click();
document.getElementById('cancel-add-tag-button').click();
expect(document.getElementById('add-tag-button').classList).not.toContain(
'hidden',
);
expect(document.getElementById('add-tag-container').classList).toContain(
'hidden',
);
});
it('click on add-tag button hides the container and shows add-tag button', async () => {
fetch.mockResponseOnce(tagResponse());
const addTagButton = document.getElementById('add-tag-button');
addTagButton.click();
fireEvent.input(document.getElementById('admin-add-tag'), {
target: { value: 'New Tag' },
});
expect(
document.getElementById('tag-add-submit').hasAttribute('disabled'),
).toBeFalsy();
document.getElementById('tag-add-reason').value = 'Adding a new tag';
document.getElementById('tag-add-submit').click();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('shows the adjustment container when admin input is focused', () => {
document.getElementById('add-tag-button').click();
document.getElementById('admin-add-tag').value = 'pizza';
document.getElementById('tag-add-reason').value = 'Adding a new tag';
document.getElementById('tag-add-submit').click();
expect(
document.getElementById('add-reason-container').classList,
).not.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">
describe('article has 1 tag', () => {
const discussTag = 'discuss';
function removeTagResponse() {
return JSON.stringify({
status: 'Success',
result: 'removal',
colors: {
bg: '#8c5595',
text: '#39ad55',
},
});
}
beforeEach(() => {
fetch.resetMocks();
document.body.innerHTML = `
<button id="remove-tag-button-${discussTag}" class="adjustable-tag" type="button" data-adjustment-type="subtract" data-tag-name="${discussTag}" data-article-id="32">
<span class="num-sign">#</span>${discussTag}
<div id="remove-tag-icon-${discussTag}" class="circle centered-icon adjustment-icon subtract color-base-inverted hidden">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" role="img"><title id="random">Remove tag</title>
<path d="M19 11H5v2h14v-2Z"></path>
</svg>
</div>
</button>
<div id="remove-tag-container-${discussTag}" class="hidden">
<div id="adjustment-reason-container-${discussTag}" class="reason-container">
<textarea class="crayons-textfield" placeholder="Reason to remove tag (optional)" id="tag-removal-reason-${discussTag}"></textarea>
<div class="flex gap-3">
<button class="crayons-btn" id="remove-tag-submit-${discussTag}">Submit</button>
<button class="c-btn" id="cancel-remove-tag-button-${discussTag}" type="button">Cancel</button>
</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">
<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.getElementsByClassName('adjustable-tag')[0].click();
document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click();
expect(
document.getElementById('adjustment-reason-container').classList,
).not.toContain('hidden');
</div>
</div>
`;
handleRemoveTagButtonsListeners();
});
it('remove tag container is hidden', () => {
expect(
document.getElementById(`remove-tag-container-${discussTag}`).classList,
).toContain('hidden');
});
it('hides remove icon on button click', () => {
document.getElementById(`remove-tag-button-${discussTag}`).click();
expect(
document.getElementById(`remove-tag-icon-${discussTag}`).style.display,
).toEqual('none');
});
it('shows tag container on button click', () => {
document.getElementById(`remove-tag-button-${discussTag}`).click();
expect(
document.getElementById(`remove-tag-container-${discussTag}`).classList,
).not.toContain('hidden');
});
it('click on cancel button hides the container', () => {
document.getElementById(`remove-tag-button-${discussTag}`).click();
document.getElementById(`cancel-remove-tag-button-${discussTag}`).click();
expect(
document.getElementById(`remove-tag-container-${discussTag}`).classList,
).toContain('hidden');
});
it('click on remove button should successfully remove the item', async () => {
fetch.mockResponseOnce(removeTagResponse());
document.getElementById(`remove-tag-button-${discussTag}`).click();
document.getElementById(`tag-removal-reason-${discussTag}`).value =
'Removing a tag';
document.getElementById(`remove-tag-submit-${discussTag}`).click();
expect(fetch).toHaveBeenCalledTimes(1);
});
});
describe('tag moderator role', () => {
const discussTag = 'discuss';
function addModTagResponse() {
return JSON.stringify({
status: 'Success',
result: 'addition',
colors: {
bg: '#8c5595',
text: '#39ad55',
},
});
}
beforeEach(() => {
fetch.resetMocks();
document.body.innerHTML = `
<button
id="add-tag-button-${discussTag}"
class="adjustable-tag add-tag" type="button"
data-adjustment-type="plus"
data-tag-name="${discussTag}"
data-article-id="<%= @moderatable.id %>">
<span class="num-sign">#</span>${discussTag}
<div id="add-tag-icon-${discussTag}" class="circle centered-icon add-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" role="img"><title id="random">Remove tag</title>
<path d="M19 11H5v2h14v-2Z"></path>
</svg>
</div>
</button>
<div id="add-tag-container-${discussTag}" class="hidden">
<div id="add-reason-container-${discussTag}" class="reason-container">
<textarea id="tag-add-reason-${discussTag}" class="crayons-textfield mt-3" placeholder="<%= t("views.moderations.actions.adjust.reason.add_tag") %>"></textarea>
<div class="flex gap-3">
<button class="w-100 c-btn c-btn--primary" id="tag-add-submit-${discussTag}"><%= t("views.moderations.actions.adjust.add") %></button>
<button class="w-100 c-btn" id="cancel-add-tag-button-${discussTag}" type="button"><%= t("views.moderations.actions.adjust.cancel") %></button>
</div>
</div>
</div>
`;
handleAddModTagButtonsListeners();
});
it('add moderator tag container is hidden', () => {
expect(
document.getElementById(`add-tag-container-${discussTag}`).classList,
).toContain('hidden');
});
it('hides add icon on button click', () => {
document.getElementById(`add-tag-button-${discussTag}`).click();
expect(
document.getElementById(`add-tag-icon-${discussTag}`).style.display,
).toEqual('none');
});
it('shows tag container on button click', () => {
document.getElementById(`add-tag-button-${discussTag}`).click();
expect(
document.getElementById(`add-tag-container-${discussTag}`).classList,
).not.toContain('hidden');
});
it('click on cancel button hides the container', () => {
document.getElementById(`add-tag-button-${discussTag}`).click();
document.getElementById(`cancel-add-tag-button-${discussTag}`).click();
expect(
document.getElementById(`add-tag-container-${discussTag}`).classList,
).toContain('hidden');
});
it('click on add button should successfully add the item', async () => {
fetch.mockResponseOnce(addModTagResponse());
document.getElementById(`add-tag-button-${discussTag}`).click();
document.getElementById(`tag-add-reason-${discussTag}`).value =
'Add a tag';
document.getElementById(`tag-add-submit-${discussTag}`).click();
expect(fetch).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -6,6 +6,11 @@ import { request } from '@utilities/http';
export function addCloseListener() {
const button = document.getElementsByClassName('close-actions-panel')[0];
const parentPath = window.parent.location.pathname;
if (!parentPath.startsWith('/mod')) {
button.classList.remove('hidden');
}
button.addEventListener('click', () => {
// getting the article show page document because this is called within an iframe
// eslint-disable-next-line no-restricted-globals
@ -182,10 +187,6 @@ const adminFeatureArticle = async (id, featured) => {
}
};
function clearAdjustmentReason() {
document.getElementById('tag-adjustment-reason').value = '';
}
function renderTagOnArticle(tagName, colors) {
const articleTagsContainer =
getArticleContainer().getElementsByClassName('spec__tags')[0];
@ -208,24 +209,31 @@ function getArticleContainer() {
: window.parent.document.getElementById('main-content');
}
async function adjustTag(el) {
const reasonForAdjustment = document.getElementById(
'tag-adjustment-reason',
).value;
/**
* This function sends an asynchronous request to the server to add or remove
* a specific tag from an article.
*/
async function adjustTag(el, reasonElement) {
const tagName = el.dataset.tagName || el.value;
const body = {
tag_adjustment: {
// TODO: change to tag ID
tag_name: el.dataset.tagName || el.value,
tag_name: tagName,
article_id: el.dataset.articleId,
adjustment_type:
el.dataset.adjustmentType === 'subtract' ? 'removal' : 'addition',
reason_for_adjustment: reasonForAdjustment,
reason_for_adjustment: reasonElement.value,
},
};
try {
const response = await request('/tag_adjustments', {
const response = await fetch('/tag_adjustments', {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
@ -235,15 +243,12 @@ async function adjustTag(el) {
let adjustedTagName;
if (el.tagName === 'BUTTON') {
adjustedTagName = el.dataset.tagName;
el.remove();
} else {
adjustedTagName = el.value;
// eslint-disable-next-line no-param-reassign, require-atomic-updates
el.value = '';
}
clearAdjustmentReason();
if (outcome.result === 'addition') {
renderTagOnArticle(adjustedTagName, outcome.colors);
} else {
@ -259,6 +264,7 @@ async function adjustTag(el) {
}.`,
addCloseButton: true,
});
window.location.reload();
} else {
// eslint-disable-next-line no-restricted-globals
top.addSnackbarItem({
@ -272,81 +278,174 @@ async function adjustTag(el) {
}
}
export function handleAdjustTagBtn(btn) {
const currentActiveTags = document.querySelectorAll(
'button.adjustable-tag.active',
export function handleAddModTagButton(btn) {
const { tagName } = btn.dataset;
const addButton = document.getElementById(`add-tag-button-${tagName}`);
const addIcon = document.getElementById(`add-tag-icon-${tagName}`);
const addTagContainer = document.getElementById(
`add-tag-container-${tagName}`,
);
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');
});
const containerIsVisible = addTagContainer.classList.contains('hidden');
if (containerIsVisible) {
addIcon.style.display = 'none';
addTagContainer.classList.remove('hidden');
addButton.classList.add('fw-bold');
addButton.classList.remove('fw-normal');
} else {
btn.classList.toggle('active');
addIcon.style.display = 'flex';
addTagContainer.classList.add('hidden');
addButton.classList.remove('fw-bold');
addButton.classList.add('fw-normal');
}
const cancelAddModTagButton = document.getElementById(
`cancel-add-tag-button-${tagName}`,
);
cancelAddModTagButton.addEventListener('click', () => {
handleAddModTagButton(btn);
});
const addTagButton = document.getElementById(`tag-add-submit-${tagName}`);
if (addTagButton) {
addTagButton.addEventListener('click', (e) => {
e.preventDefault();
const dataSource = document.getElementById(`add-tag-button-${tagName}`);
const reasonFoRemoval = document.getElementById(
`tag-add-reason-${tagName}`,
);
adjustTag(dataSource, reasonFoRemoval);
});
}
}
function handleAdminInput() {
const addTagInput = document.getElementById('admin-add-tag');
/**
* Handles various listeners required to handle remove tag functionality.
*/
export function handleRemoveTagButton(btn) {
const { tagName } = btn.dataset;
if (addTagInput) {
addTagInput.addEventListener('focus', () => {
const activeTagBtns = Array.from(
document.querySelectorAll('button.adjustable-tag.active'),
const removeButton = document.getElementById(`remove-tag-button-${tagName}`);
const removeIcon = document.getElementById(`remove-tag-icon-${tagName}`);
const removeTagContainer = document.getElementById(
`remove-tag-container-${tagName}`,
);
if (!(removeButton && removeIcon && removeTagContainer)) {
return false;
}
const containerIsVisible = removeTagContainer?.classList.contains('hidden');
if (containerIsVisible) {
removeIcon.style.display = 'none';
removeTagContainer.classList.remove('hidden');
removeButton.classList.add('fw-bold');
removeButton.classList.remove('fw-normal');
} else {
removeIcon.style.display = 'flex';
removeTagContainer.classList.add('hidden');
removeButton.classList.remove('fw-bold');
removeButton.classList.add('fw-normal');
}
const cancelRemoveTagButton = document.getElementById(
`cancel-remove-tag-button-${tagName}`,
);
cancelRemoveTagButton.addEventListener('click', () => {
handleRemoveTagButton(btn);
});
const removeTagButton = document.getElementById(
`remove-tag-submit-${tagName}`,
);
if (removeTagButton) {
removeTagButton.addEventListener('click', (e) => {
e.preventDefault();
const dataSource = document.getElementById(
`remove-tag-button-${tagName}`,
);
activeTagBtns.forEach((btn) => {
btn.classList.remove('active');
});
const reasonFoRemoval = document.getElementById(
`tag-removal-reason-${tagName}`,
);
adjustTag(dataSource, reasonFoRemoval);
});
addTagInput.addEventListener('focusout', () => {
if (addTagInput.value === '') {
}
}
/**
* Handles various listeners required to handle add tag functionality.
*/
export function handleAddTagButtonListeners() {
const inputTag = document.getElementById('admin-add-tag');
const submitButton = document.getElementById('tag-add-submit');
if (inputTag) {
inputTag.addEventListener('input', () => {
if (inputTag.value.trim().length > 0) {
submitButton.removeAttribute('disabled');
} else {
submitButton.setAttribute('disabled', 'disabled');
}
});
}
const addTagButton = document.getElementById('add-tag-button');
if (addTagButton) {
addTagButton.addEventListener('click', () => {
const addTagContainer = document.getElementById('add-tag-container');
addTagContainer.classList.remove('hidden');
addTagButton.classList.add('hidden');
});
const cancelAddTagButton = document.getElementById('cancel-add-tag-button');
if (cancelAddTagButton) {
cancelAddTagButton.addEventListener('click', () => {
const addTagContainer = document.getElementById('add-tag-container');
addTagContainer.classList.add('hidden');
addTagButton.classList.remove('hidden');
});
}
const addTagSubmitButton = document.getElementById('tag-add-submit');
if (addTagSubmitButton) {
addTagSubmitButton.addEventListener('click', (e) => {
e.preventDefault();
const dataSource = document.getElementById('admin-add-tag');
const reasonFoAddition = document.getElementById('tag-add-reason');
adjustTag(dataSource, reasonFoAddition);
});
}
}
}
export function addAdjustTagListeners() {
Array.from(document.getElementsByClassName('adjustable-tag')).forEach(
export function handleAddModTagButtonsListeners() {
Array.from(document.getElementsByClassName('adjustable-tag add-tag')).forEach(
(btn) => {
btn.addEventListener('click', () => {
handleAdjustTagBtn(btn);
handleAddModTagButton(btn);
});
},
);
}
const form = document.getElementById('tag-adjust-submit')?.form;
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const dataSource =
document.querySelector('button.adjustable-tag.active') ??
document.getElementById('admin-add-tag');
adjustTag(dataSource);
});
handleAdminInput();
}
export function handleRemoveTagButtonsListeners() {
Array.from(document.getElementsByClassName('adjustable-tag')).forEach(
(btn) => {
btn.addEventListener('click', () => {
handleRemoveTagButton(btn);
});
},
);
}
export function addModActionsListeners() {
addAdjustTagListeners();
handleAddTagButtonListeners();
handleAddModTagButtonsListeners();
handleRemoveTagButtonsListeners();
Array.from(document.getElementsByClassName('other-things-btn')).forEach(
(btn) => {
btn.addEventListener('click', () => {

View file

@ -170,21 +170,7 @@ class ArticlePolicy < ApplicationPolicy
def tag_moderator_eligible?
tag_ids_moderated_by_user = Tag.with_role(:tag_moderator, @user).ids
return false unless tag_ids_moderated_by_user.size.positive?
adjustments = TagAdjustment.where(article_id: @record.id)
has_room_for_tags = @record.tag_list.size < MAX_TAG_LIST_SIZE
# ensures that mods cannot adjust an already-adjusted tag
# "zero?" because intersection has just one integer (0 or 1)
has_no_relevant_adjustments = adjustments.pluck(:tag_id).intersection(tag_ids_moderated_by_user).empty?
# tag_mod can add their moderated tags
return true if has_room_for_tags && has_no_relevant_adjustments
authorized_to_adjust = @record.tags.ids.intersection(tag_ids_moderated_by_user).size.positive?
# tag_mod can remove their moderated tags
!has_room_for_tags && has_no_relevant_adjustments && authorized_to_adjust
tag_ids_moderated_by_user.size.positive?
end
def destroy?

View file

@ -93,7 +93,7 @@
<%# Checking if the user should see the "Adjust tags" dropdown %>
<% if policy(@moderatable).allow_tag_adjustment? %>
<button aria-haspopup="true" aria-expanded="false" aria-controls="adjust-tags-options" class="other-things-btn adjust-tags" type="button" data-other-things-type="adjust-tags" aria-label="<%= t("views.moderations.actions.adjust.aria_label") %>">
<button id="adjust-tags-button" aria-haspopup="true" aria-expanded="false" aria-controls="adjust-tags-options" class="other-things-btn adjust-tags" type="button" data-other-things-type="adjust-tags" aria-label="<%= t("views.moderations.actions.adjust.aria_label") %>">
<div class="label-wrapper">
<div class="icon circle centered-icon">
<%= crayons_icon_tag("mod-filled", title: t("views.moderations.actions.adjust.icon")) %>
@ -109,37 +109,100 @@
</button>
<div id="adjust-tags-options" class="adjust-tags-options dropdown-options hidden">
<% if policy(@moderatable).can_adjust_any_tag? %>
<% if @moderatable.tag_list.size < Article::MAX_TAG_LIST_SIZE %>
<div class="add-tag-container">
<input id="admin-add-tag" class="crayons-textfield" type="text" autocomplete="off" placeholder="<%= t("views.moderations.actions.adjust.add_placeholder") %>" 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 color-base-inverted">
<% @moderatable.tags.each do |tag| %>
<% moderatable_tag = @tag_moderator_tags.pluck(:name).include?(tag.name) || policy(@moderatable).can_adjust_any_tag? %>
<button
id="remove-tag-button-<%= tag.name %>"
class="adjustable-tag" type="button"
data-adjustment-type="subtract"
data-tag-name="<%= tag.name %>"
data-article-id="<%= @moderatable.id %>"
title="<%= moderatable_tag ? "" : "Not allowed to adjust this tag" %>">
<span class="num-sign">#</span><%= tag.name %>
<% if moderatable_tag %>
<div id="remove-tag-icon-<%= tag.name %>" class="circle centered-icon remove-icon hidden">
<%= crayons_icon_tag(:minus, title: t("views.moderations.actions.adjust.remove")) %>
</div>
</button>
<% end %>
<% elsif policy(@moderatable).tag_moderator_eligible? %>
<% @tag_moderator_tags.each do |tag| %>
<% subtract_or_plus = @moderatable.tag_list.include?(tag.name) ? "subtract" : "plus" %>
<% subtract_or_plus_title = t("views.moderations.actions.adjust.#{subtract_or_plus == 'subtract' ? 'remove' : 'add'}") %>
<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 %>">
<%= crayons_icon_tag(subtract_or_plus, title: subtract_or_plus_title) %>
<% end %>
</button>
<% if moderatable_tag %>
<div id="remove-tag-container-<%= tag.name %>" class="hidden">
<div id="adjustment-reason-container-<%= tag.name %>" class="reason-container">
<textarea class="crayons-textfield" placeholder="<%= t("views.moderations.actions.adjust.reason.remove_tag") %>" id="tag-removal-reason-<%= tag.name %>"></textarea>
<div class="flex gap-3 align-items-stretch">
<button class="w-100 c-btn c-btn--primary c-btn--destructive" id="remove-tag-submit-<%= tag.name %>"><%= t("views.moderations.actions.adjust.remove") %></button>
<button class="w-100 c-btn" id="cancel-remove-tag-button-<%= tag.name %>" type="button"><%= t("views.moderations.actions.adjust.cancel") %></button>
</div>
</div>
</button>
</div>
<% end %>
<% end %>
<form id="adjustment-reason-container" class="adjustment-reason-container">
<textarea class="crayons-textfield" placeholder="<%= t("views.moderations.actions.adjust.reason") %>" id="tag-adjustment-reason"></textarea>
<button class="crayons-btn" id="tag-adjust-submit" type="submit"><%= t("views.moderations.actions.adjust.submit") %></button>
</form>
<% if @moderatable.tag_list.size < Article::MAX_TAG_LIST_SIZE %>
<% if policy(@moderatable).can_adjust_any_tag? %>
<hr />
<button id="add-tag-button" data-testid="add-tag-button" class="add-tag" type="button">
<%= t("views.moderations.actions.adjust.add_tag.button_text") %>
<div class="circle centered-icon add-icon">
<%= crayons_icon_tag(:plus, title: t("views.moderations.actions.adjust.add_tag.icon")) %>
</div>
</button>
<div id="add-tag-container" class="hidden">
<div id="add-reason-container" class="reason-container">
<div class="add-tag-header"><%= t("views.moderations.actions.adjust.add_tag.header") %></div>
<input id="admin-add-tag" class="crayons-textfield mt-3" type="text" autocomplete="off" placeholder="<%= t("views.moderations.actions.adjust.add_placeholder") %>" data-article-id="<%= @moderatable.id %>" data-adjustment-type="plus">
<textarea id="tag-add-reason" class="crayons-textfield mt-3" placeholder="<%= t("views.moderations.actions.adjust.reason.add_tag") %>"></textarea>
<div class="flex gap-3">
<button class="w-100 c-btn c-btn--primary" disabled="disabled" id="tag-add-submit"><%= t("views.moderations.actions.adjust.add") %></button>
<button class="w-100 c-btn" id="cancel-add-tag-button" type="button"><%= t("views.moderations.actions.adjust.cancel") %></button>
</div>
</div>
</div>
<% else %>
<% count = 0 %>
<% @tag_moderator_tags.each do |tag| %>
<% if !@moderatable.tags.pluck(:name).include?(tag.name) %>
<% count += 1 %>
<% end %>
<% end %>
<% if count > 0 %>
<hr />
<% end %>
<% @tag_moderator_tags.each do |tag| %>
<% if !@moderatable.tags.pluck(:name).include?(tag.name) %>
<button
id="add-tag-button-<%= tag.name %>"
class="adjustable-tag add-tag" type="button"
data-adjustment-type="plus"
data-tag-name="<%= tag.name %>"
data-article-id="<%= @moderatable.id %>">
<span class="num-sign">#</span><%= tag.name %>
<div id="add-tag-icon-<%= tag.name %>" class="circle centered-icon add-icon">
<%= crayons_icon_tag(:plus, title: t("views.moderations.actions.adjust.add")) %>
</div>
</button>
<div id="add-tag-container-<%= tag.name %>" class="hidden">
<div id="add-reason-container-<%= tag.name %>" class="reason-container">
<textarea id="tag-add-reason-<%= tag.name %>" class="crayons-textfield mt-3" placeholder="<%= t("views.moderations.actions.adjust.reason.add_tag") %>"></textarea>
<div class="flex gap-3">
<button class="w-100 c-btn c-btn--primary" id="tag-add-submit-<%= tag.name %>"><%= t("views.moderations.actions.adjust.add") %></button>
<button class="w-100 c-btn" id="cancel-add-tag-button-<%= tag.name %>" type="button"><%= t("views.moderations.actions.adjust.cancel") %></button>
</div>
</div>
</div>
<% end %>
<% end %>
<% end %>
<hr />
<% else %>
<hr />
<span class="max-tags-text">
<%= crayons_icon_tag(:info, title: t("views.moderations.actions.adjust.maximum_tags.icon"), width: 18, height: 18) %>
<span>
<%= t("views.moderations.actions.adjust.maximum_tags.text") %>
</span>
</span>
<% end %>
<a href="<%= URL.url %>/tag-moderation#how-to-remove-or-add-tags" target="_blank" rel="noopener">
<span class="additional-subtext-section py-3">
<%= t("views.moderations.actions.how") %>

View file

@ -19,9 +19,19 @@ en:
icon: Adjust tags
add: Add tag
add_placeholder: Add a tag
reason: Reason for tag adjustment (optional)
reason:
remove_tag: Reason to remove tag (optional)
add_tag: Reason to add tag (optional)
remove: Remove tag
submit: Submit
cancel: Cancel
maximum_tags:
icon: Info
text: A post can only have four tags.
add_tag:
header: Add new tag
button_text: Add new tag
icon: Add tag
approve:
add: Mark as approved
remove: Remove approval

View file

@ -19,9 +19,19 @@ fr:
icon: Ajuster les étiquettes
add: Ajouter une étiquette
add_placeholder: Ajouter une étiquette
reason: Raison de l'ajustement de l'étiquette (optionnel)
reason:
remove_tag: Raison de supprimer la balise
add_tag: Raison d'ajouter la balise (optionnel)
remove: Retirer l'étiquette
submit: Soumettre
cancel: Annuler
maximum_tags:
icon: Info
text: Une publication ne peut avoir que quatre balises.
add_tag:
header: Ajouter une nouvelle balise
button_text: Ajouter une nouvelle balise
icon: Ajouter une étiquette
approve:
add: Marquer comme approuvé
remove: Retirer l'approbation

View file

@ -41,8 +41,12 @@ describe('Moderation Tools for Posts', () => {
.pipe(click)
.should('have.attr', 'aria-expanded', 'true');
cy.findByRole('button', { name: '#tag1 Remove tag' }).click();
cy.findByRole('button', { name: 'Submit' }).click();
cy.findByText('tag1').click();
cy.findByPlaceholderText('Reason to remove tag (optional)').type(
'testing',
);
cy.findByRole('button', { name: 'Remove tag' }).click();
});
cy.findByTestId('snackbar').should('not.exist');

View file

@ -17,11 +17,12 @@ describe('Adjust post tags', () => {
cy.getIframeBody('.actions-panel-iframe').within(() => {
cy.findByRole('button', { name: 'Open adjust tags section' }).click();
cy.findByTestId('add-tag-button').click();
cy.findByPlaceholderText('Add a tag').type('tag2');
cy.findByPlaceholderText('Reason for tag adjustment (optional)').type(
cy.findByPlaceholderText('Reason to add tag (optional)').type(
'testing',
);
cy.findByRole('button', { name: 'Submit' }).click();
cy.findByRole('button', { name: 'Add tag' }).click();
});
cy.getIframeBody('.article-iframe').within(() => {
@ -38,12 +39,12 @@ describe('Adjust post tags', () => {
cy.getIframeBody('.actions-panel-iframe').within(() => {
cy.findByRole('button', { name: 'Open adjust tags section' }).click();
cy.findByPlaceholderText('Reason for tag adjustment (optional)').type(
cy.findByText('tag1').click();
cy.findByPlaceholderText('Reason to remove tag (optional)').type(
'testing',
);
cy.findByRole('button', { name: '#tag1 Remove tag' }).click();
cy.findByRole('button', { name: 'Submit' }).click();
cy.findByRole('button', { name: 'Remove tag' }).click();
});
cy.getIframeBody('.article-iframe').within(() => {
@ -71,11 +72,12 @@ describe('Adjust post tags', () => {
cy.getIframeBody('.actions-panel-iframe').within(() => {
cy.findByRole('button', { name: 'Open adjust tags section' }).click();
cy.findByTestId('add-tag-button').click();
cy.findByPlaceholderText('Add a tag').type('tag2');
cy.findByPlaceholderText('Reason for tag adjustment (optional)').type(
cy.findByPlaceholderText('Reason to add tag (optional)').type(
'testing',
);
cy.findByRole('button', { name: 'Submit' }).click();
cy.findByRole('button', { name: 'Add tag' }).click();
});
cy.getIframeBody('.article-iframe').within(() => {
@ -92,12 +94,13 @@ describe('Adjust post tags', () => {
cy.getIframeBody('.actions-panel-iframe').within(() => {
cy.findByRole('button', { name: 'Open adjust tags section' }).click();
cy.findByPlaceholderText('Reason for tag adjustment (optional)').type(
cy.findByText('tag1').click();
cy.findByPlaceholderText('Reason to remove tag (optional)').type(
'testing',
);
cy.findByRole('button', { name: '#tag1 Remove tag' }).click();
cy.findByRole('button', { name: 'Submit' }).click();
cy.findByRole('button', { name: 'Remove tag' }).click();
});
cy.getIframeBody('.article-iframe').within(() => {
@ -135,11 +138,12 @@ describe('Adjust post tags', () => {
.pipe(click)
.should('have.attr', 'aria-expanded', 'true');
cy.findByTestId('add-tag-button').click();
cy.findByPlaceholderText('Add a tag').type('tag2');
cy.findByPlaceholderText('Reason for tag adjustment (optional)').type(
cy.findByPlaceholderText('Reason to add tag (optional)').type(
'testing',
);
cy.findByRole('button', { name: 'Submit' }).click();
cy.findByRole('button', { name: 'Add tag' }).click();
});
cy.get('@main').within(() => {
@ -162,12 +166,12 @@ describe('Adjust post tags', () => {
.pipe(click)
.should('have.attr', 'aria-expanded', 'true');
cy.findByPlaceholderText('Reason for tag adjustment (optional)').type(
cy.findByText('tag1').click();
cy.findByPlaceholderText('Reason to remove tag (optional)').type(
'testing',
);
cy.findByRole('button', { name: '#tag1 Remove tag' }).click();
cy.findByRole('button', { name: 'Submit' }).click();
cy.findByRole('button', { name: 'Remove tag' }).click();
});
cy.get('@main').within(() => {

View file

@ -37,7 +37,7 @@ RSpec.describe ArticlePolicy do
it "omits suspended and regular users" do
results = described_class.scope_users_authorized_to_action(users_scope: User, action: :create?).to_a
expect(results).to match_array([super_admin])
expect(results).to contain_exactly(super_admin)
end
end
@ -46,7 +46,7 @@ RSpec.describe ArticlePolicy do
it "omits only suspended users" do
results = described_class.scope_users_authorized_to_action(users_scope: User, action: :create?).to_a
expect(results).to match_array([author, super_admin])
expect(results).to contain_exactly(author, super_admin)
end
end
end
@ -174,7 +174,7 @@ RSpec.describe ArticlePolicy do
context "when article excludes tagmod_tag, has no room for more tags, and no relevant adjustments" do
let(:resource) { create(:article, tags: "tagtwo, tagthree, tagfour, tagfive") }
it { is_expected.not_to be_tag_moderator_eligible }
it { is_expected.to be_tag_moderator_eligible }
end
context "when tag moderator's tag has been adjusted" do
@ -189,7 +189,7 @@ RSpec.describe ArticlePolicy do
tag_name: tagmod_tag,
adjustment_type: "removal",
)
expect(policy).not_to be_tag_moderator_eligible
expect(policy).to be_tag_moderator_eligible
end
end

View file

@ -94,7 +94,7 @@ RSpec.describe "Moderations" do
sign_in tag_mod
get "#{article1.path}/actions_panel"
expect(response.body).to include "circle centered-icon adjustment-icon subtract"
expect(response.body).to include "circle centered-icon remove-icon"
end
it "shows the option to add the tag when the article has the tag" do
@ -102,7 +102,7 @@ RSpec.describe "Moderations" do
sign_in tag_mod
get "#{article2.path}/actions_panel"
expect(response.body).to include "circle centered-icon adjustment-icon plus"
expect(response.body).to include "circle centered-icon add-icon"
end
end
end
@ -133,7 +133,7 @@ RSpec.describe "Moderations" do
it "shows the admin tag options", :aggregate_failures do
expect(response.body).to include "admin-add-tag"
expect(response.body).to include "circle centered-icon adjustment-icon subtract"
expect(response.body).to include "circle centered-icon remove-icon"
end
end