Mod Center MVP 🎉 (#8744)

Co-authored-by: Andy Zhao <17884966+Zhao-Andy@users.noreply.github.com>
This commit is contained in:
Arit Amana 2020-06-23 14:58:16 -04:00 committed by GitHub
parent 40f54e317f
commit e45742c212
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 492 additions and 227 deletions

View file

@ -2,163 +2,87 @@
@import 'mixins';
@import 'config/import';
.mod-index-hero {
margin: 0 auto;
padding: var(--su-7) var(--su-1);
text-align: center;
background: var(--body-bg);
h1 {
font-size: calc(1.8em + 2vw);
}
h2 {
font-size: calc(0.9em + 1vw);
}
a {
text-decoration: underline;
}
.mod-index-hero-data-pod {
display: inline-block;
margin: 10px;
width: 90%;
font-weight: bold;
border: 3px solid var(--body-color);
padding: 20px 0px;
.mod-index-hero-data-pod-number {
font-size: 2.8em;
}
@media screen and (min-width: 960px) {
width: calc(190px + 11vw);
padding: 80px 0px;
margin: 15px 2px;
.mod-index-hero-data-pod-number {
font-size: calc(2.7em + 0.2vw);
}
}
@media screen and (min-width: 1300px) {
width: calc(260px + 8vw);
padding: 80px 0px;
margin: 30px;
.mod-index-hero-data-pod-number {
font-size: calc(2.8em + 0.5vw);
}
}
}
.mod-index-hero-sub {
font-size: 0.9em;
}
.mod-center-heading {
margin-bottom: var(--su-6);
}
.mod-index-header {
margin: 50px auto 30px;
width: 96%;
max-width: 1600px;
padding-left: 20px;
.mod-index-header-top-filters {
padding: 10px 0px 25px;
a {
color: var(--body-color);
font-size: 0.86em;
padding: 3px 18px;
border-radius: 100px;
border: 1px solid transparent;
&.mod-index-top-filter-link--active {
border: 1px solid var(--body-color);
}
}
}
a.mod-index-header-link {
color: $black;
padding: 5px 15px;
border-radius: 3px;
font-size: 1.5em;
font-weight: bold;
border: 1px solid transparent;
}
a.mod-index-header-link--active {
@include themeable(border, theme-border, 1px solid $black);
}
}
.mod-index-container {
.mod-index-header {
background-color: var(--base-inverted);
border-radius: var(--radius) var(--radius) 0 0;
border-bottom: 1px solid var(--base-10);
padding: var(--su-6) var(--su-4);
.mod-index-list {
margin: 5px auto;
width: 96%;
max-width: 1600px;
border-radius: 3px;
border: 1px solid var(--card-color-tertiary);
background: var(--card-bg);
.mod-index-row {
padding: 10px;
@include themeable(
border-bottom,
theme-border,
1px solid $light-medium-gray
);
a.mod-index-article-title {
font-size: calc(1.2em + 0.3vw);
font-weight: bold;
margin-right: 6px;
display: inline-block;
color: var(--body-color);
.tag-view {
color: var(--base-90);
}
.mod-index-article-tagslist {
display: inline-block;
}
.mod-index-list-header {
display: grid;
grid-template-columns: 4fr 2fr 1fr;
background-color: var(--base-0);
border: solid var(--base-10);
border-width: 1px 0;
h2 {
padding: var(--su-2) var(--su-4);
color: var(--base-50);
}
a.mod-index-article-tag {
font-size: 0.9em;
padding: 3px 8px;
border-radius: 3px;
background: $light-gray;
color: $black;
vertical-align: 0.2em;
}
.mod-index-actions-and-details {
img {
height: 1.4em;
width: 1.4em;
border-radius: 100px;
margin-right: 5px;
display: inline-block;
vertical-align: -0.3em;
}
a {
font-weight: 500;
color: var(--card-color-tertiary);
}
}
.quick-mod-button {
font-size: 0.95em;
padding: 5px 18px;
border-radius: 100px;
@include themeable(border, theme-border, 1px solid $medium-gray);
display: inline-block;
margin: 10px 0px 5px;
margin-right: 5px;
background: transparent;
color: var(--body-color);
}
.mod-index-row-iframes {
padding: 5px 0px;
display: flex;
iframe {
height: calc(100vh - 100px);
max-height: 800px;
}
.mod-index-list {
margin: 0;
width: 100%;
.moderation-articles-list {
.moderation-single-article {
width: 100%;
border: 0px;
display: inline-block;
}
.actions-panel {
width: 36%;
background-color: var(--base-inverted);
text-align: left;
padding: var(--su-4);
border: solid var(--base-10);
border-width: 1px 0;
display: grid;
grid-template-columns: 4fr 2fr 1fr;
.mod-article-tag {
color: var(--base-80);
padding-right: var(--su-2);
.article-hash-tag {
color: var(--base-40);
}
}
.article-author {
color: var(--base-80);
padding-left: var(--su-4);
}
.article-published-at {
color: var(--base-90);
padding-left: var(--su-6);
}
.article-iframes-container {
border-radius: var(--radius);
margin-top: var(--su-3);
grid-column-start: 1;
grid-column-end: 4;
width: 100%;
display: flex;
&.opened {
border: 1px solid var(--base-20);
}
iframe {
border: none;
}
.article-iframe {
width: 100%;
height: 650px; // height is arbitrary, will be removed for modal-esque view
}
.actions-panel-iframe {
width: 60%;
height: 650px; // height is arbitrary, will be removed for modal-esque view
}
}
}
}
}
}
.mod-index-footer {
text-align: center;
padding: 30px 10px;
}
// these styles are to correct height and margin issues in the iframe
.moderations-actions_panel {
min-height: initial;
@ -763,3 +687,29 @@
padding: 3px 8px;
border-radius: 3px;
}
.mod-center-section-title {
padding: var(--su-2);
&.resources {
margin-top: var(--su-7);
}
}
.mod-center-inbox {
.inbox-tags {
display: flex;
justify-content: space-between;
padding-left: var(--su-3);
padding-right: var(--su-3);
}
}
#mod-center-other-options svg {
width: var(--su-3);
}
.mod-feedback {
margin-top: var(--su-6);
margin-left: var(--su-3);
}

View file

@ -1,17 +1,26 @@
class ModerationsController < ApplicationController
after_action :verify_authorized
JSON_OPTIONS = {
only: %i[id title published_at cached_tag_list path],
include: {
user: { only: %i[username name path articles_count] }
}
}.freeze
def index
skip_authorization
return unless current_user&.trusted
@articles = Article.published.
articles = Article.published.
where("score > -5 AND score < 5").
order("published_at DESC").limit(70)
@articles = @articles.cached_tagged_with(params[:tag]) if params[:tag].present?
@articles = @articles.where("nth_published_by_author > 0 AND nth_published_by_author < 4 AND published_at > ?", 7.days.ago) if params[:state] == "new-authors"
@articles = @articles.decorate
articles = articles.cached_tagged_with(params[:tag]) if params[:tag].present?
articles = articles.where("nth_published_by_author > 0 AND nth_published_by_author < 4 AND published_at > ?", 7.days.ago) if params[:state] == "new-authors"
@articles = articles.includes(:user).to_json(JSON_OPTIONS)
@tag = Tag.find_by(name: params[:tag]) || not_found if params[:tag].present?
@current_user_tags = current_user.moderator_for_tags
@community_mod_channel = current_user.chat_channels.find_by("channel_name LIKE ?", "Community Mods: Team%")
end
def article

View file

@ -24,7 +24,8 @@ export default function initializeActionsPanel(user, path) {
document.querySelector('.mod-actions-menu').innerHTML = modActionsMenuHTML;
// eslint-disable-next-line no-restricted-globals
if (!top.document.location.pathname.endsWith('/mod')) {
if (!top.document.location.pathname.includes('/mod')) {
// don't show mod button in mod center page
document.getElementById(
'mod-actions-menu-btn-area',
).innerHTML = modActionsMenuIconHTML;

View file

@ -0,0 +1,40 @@
import { h, Component } from 'preact';
import SingleArticle from './singleArticle';
export class ModerationArticles extends Component {
state = {
articles: JSON.parse(
document.getElementById('mod-index-list').dataset.articles,
),
};
render() {
const { articles } = this.state;
return (
<div className="moderation-articles-list">
{articles.map((article) => {
const {
id,
title,
path,
cached_tag_list: cachedTagList,
published_at: publishedAt,
user,
} = article;
return (
<SingleArticle
id={id}
title={title}
path={path}
cachedTagList={cachedTagList}
key={id}
publishedAt={publishedAt}
user={user}
/>
);
})}
</div>
);
}
}

View file

@ -0,0 +1,13 @@
import PropTypes from 'prop-types';
export default PropTypes.shape({
id: PropTypes.number,
title: PropTypes.string,
path: PropTypes.string,
cached_tag_list: PropTypes.arrayOf(PropTypes.string),
user: PropTypes.shape({
name: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
}),
});

View file

@ -0,0 +1,82 @@
import PropTypes from 'prop-types';
import { h, Component } from 'preact';
import { formatDate } from './util';
export default class SingleArticle extends Component {
constructor(props) {
super(props);
this.state = {
articleOpened: false,
};
}
toggleArticle = (e) => {
e.preventDefault();
const { id, path } = this.props;
const { articleOpened } = this.state;
if (articleOpened) {
this.setState({ articleOpened: false });
document.getElementById(`article-iframe-${id}`).innerHTML = '';
} else {
this.setState({ articleOpened: true });
document.getElementById(
`article-iframe-${id}`,
).innerHTML = `<iframe class="article-iframe" src="${path}"></iframe><iframe class="actions-panel-iframe" src="${path}/actions_panel"></iframe>`;
}
};
render() {
const { articleOpened } = this.state;
const { id, title, publishedAt, cachedTagList, user, key } = this.props;
const tags = cachedTagList.split(', ').map((tag) => {
return (
<span className="mod-article-tag fs-s ff-accent lh-base" key={key}>
<span className="article-hash-tag">#</span>
{tag}
</span>
);
});
const newAuthorNotification = user.articles_count <= 3 ? '👋 ' : '';
return (
<button
type="button"
className="moderation-single-article"
onClick={this.toggleArticle}
>
<span className="article-title">
<header>
<h3 className="fs-base fw-bold lh-tight">{title}</h3>
</header>
{tags}
</span>
<span className="article-author fs-s lw-medium lh-tight">
{newAuthorNotification}
{user.name}
</span>
<span className="article-published-at fs-s fw-bold lh-tight">
<time dateTime={publishedAt}>{formatDate(publishedAt)}</time>
</span>
<div
className={`article-iframes-container ${
articleOpened ? 'opened' : ''
}`}
id={`article-iframe-${id}`}
/>
</button>
);
}
}
SingleArticle.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
publishedAt: PropTypes.string.isRequired,
cachedTagList: PropTypes.isRequired,
user: PropTypes.isRequired,
key: PropTypes.number.isRequired,
};

View file

@ -0,0 +1,103 @@
import { h } from 'preact';
import { axe } from 'jest-axe';
import { render, getNodeText, fireEvent } from '@testing-library/preact';
import SingleArticle from './index';
const testArticle = {
id: 1,
title: 'An article title',
path: 'an-article-title-di3',
publishedAt: '2019-06-22T16:11:10.590Z',
cachedTagList: 'discuss, javascript, beginners',
user: {
articles_count: 1,
name: 'hello',
},
};
describe('<SingleArticle />', () => {
const renderSingleArticle = (article = testArticle) =>
render(
<SingleArticle
id={article.title}
title={article.title}
path={article.path}
publishedAt={article.publishedAt} // renders as Jun 28
cachedTagList={article.cachedTagList}
user={article.user}
/>,
);
it('should have no a11y violations', async () => {
const { container } = renderSingleArticle();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('renders the article title', () => {
const { getByText } = renderSingleArticle();
getByText(testArticle.title);
});
it('renders the tags', () => {
const { getByText } = renderSingleArticle();
getByText('discuss');
getByText('javascript');
getByText('beginners');
});
it('renders the author name', () => {
const { container } = renderSingleArticle();
const text = getNodeText(container.querySelector('.article-author'));
expect(text).toContain(testArticle.user.name);
});
it('renders the hand wave emoji if the author has less than 3 articles ', () => {
const { container } = renderSingleArticle();
const text = getNodeText(container.querySelector('.article-author'));
expect(text).toContain('👋');
});
it('renders the correct formatted published date', () => {
const { getByText } = renderSingleArticle();
getByText('Jun 22');
});
it('renders the correct formatted published date as a time if the date is the same day', () => {
const today = new Date();
today.setSeconds('00');
testArticle.publishedAt = today.toISOString();
const { getByText } = renderSingleArticle(testArticle);
const readableTime = today.toLocaleTimeString().replace(':00 ', ' '); // looks like 8:05 PM
getByText(readableTime);
});
it('renders the iframes on click', () => {
const { container } = renderSingleArticle();
container.querySelector('button.moderation-single-article').click();
const iframes = container.querySelectorAll('iframe');
expect(iframes.length).toEqual(2);
const [articleIframe, actionPanelIframe] = iframes;
expect(articleIframe.src).toContain(testArticle.path);
expect(actionPanelIframe.src).toContain(
`${testArticle.path}/actions_panel`,
);
});
it('adds the opened class when opening an article', () => {
const toggleArticle = jest.fn();
const { container } = render(
<SingleArticle
id={testArticle.title}
title={testArticle.title}
path={testArticle.path}
publishedAt={testArticle.publishedAt} // renders as Jun 28
cachedTagList={testArticle.cachedTagList}
user={testArticle.user}
toggleArticle={toggleArticle}
/>,
);
fireEvent.click(
container.querySelector('button.moderation-single-article'),
);
expect(
container.querySelector('.article-iframes-container').classList,
).toContain('opened');
});
});

View file

@ -0,0 +1,20 @@
const time_options = {
hour: 'numeric',
minute: 'numeric',
};
const date_options = {
month: 'short',
day: 'numeric',
};
export const formatDate = (timestamp, currentLocale) => {
const dateToday = new Date();
const articlePublished = new Date(timestamp);
const locale = currentLocale || 'default';
if (dateToday.toDateString() === articlePublished.toDateString()) {
return articlePublished.toLocaleString(locale, time_options);
}
return articlePublished.toLocaleString(locale, date_options);
};

View file

@ -0,0 +1,38 @@
import { h, render } from 'preact';
import { ModerationArticles } from '../modCenter/moderationArticles';
import { addSnackbarItem, Snackbar } from '../Snackbar';
let elementLoaded = false;
function loadElement() {
const root = document.getElementById('mod-index-list');
const isMobileDevice = typeof window.orientation !== 'undefined';
const snackZone = document.getElementById('snack-zone');
if (root) {
render(<ModerationArticles />, root);
}
if (snackZone) {
render(<Snackbar lifespan="0" />, snackZone);
}
if (isMobileDevice) {
addSnackbarItem({
message: 'The Mod Center is best viewed on desktop.',
addCloseButton: true,
});
}
}
window.InstantClick.on('change', () => {
if (!elementLoaded) {
loadElement();
elementLoaded = true;
}
});
if (!elementLoaded) {
loadElement();
elementLoaded = true;
}

View file

@ -0,0 +1,51 @@
<div class="crayons-layout__sidebar-left">
<p class="fw-heavy fs-3xl mod-center-heading">Mod Center</p>
<div class="fw-bold fs-s mod-center-section-title">
INBOX
</div>
<div id="mod-center-inbox" class="mod-center-inbox">
<% if @current_user_tags.any? %>
<% @current_user_tags.each do |tag| %>
<a href="<%= "#{URL.url}/mod/#{tag}" %>" class="crayons-link crayons-link--block inbox-tags<%= " crayons-link--current" if tag == @tag&.name %>" data-tag-name="<%= tag %>">
#<%= tag %>
<span id="<%= tag %>-indicator" class="crayons-indicator"></span>
</a>
<% end %>
<% end %>
<a
href="<%= "#{URL.url}/mod" %>"
class="crayons-link crayons-link--block inbox-tags<%= " crayons-link--current" if @tag.blank? %>"
data-tag-name="All topics">
All topics
</a>
</div>
<div class="fw-bold fs-s mod-center-section-title resources">RESOURCES</div>
<div id="mod-center-other-options">
<% if @community_mod_channel %>
<a href="/connect/<%= @community_mod_channel.slug %>" target="_blank" rel="noopener noreferrer nofollow" class="crayons-link crayons-link--block">
Connect Channel
<span class="crayons-icon">
<%= inline_svg_tag("external-link-logo.svg", aria: true, title: "External link") %>
</span>
</a>
<% end %>
<a href="<%= URL.url %>/community-moderation" class="crayons-link crayons-link--block">
Moderator Guide
<span class="crayons-icon">
<%= inline_svg_tag("external-link-logo.svg", aria: true, title: "External link") %>
</span>
</a>
<a href="<%= URL.url %>/terms" class="crayons-link crayons-link--block">
Terms Of Conduct
<span class="crayons-icon">
<%= inline_svg_tag("external-link-logo.svg", aria: true, title: "External link") %>
</span>
</a>
</div>
<% if @community_mod_channel %>
<div class="fs-s mod-feedback">
<span class="fw-medium">Have feedback to improve your Mod experience?</span>
<span>Please share in your Connect Channel!</span>
</div>
<% end %>
</div>

View file

@ -0,0 +1,10 @@
<div id="sidebar-wrapper-right" class="sidebar-wrapper sidebar-wrapper-right crayons-layout__sidebar-right">
<div id="quick-stat-card" class="crayons-card">
<div class="crayons-card__body mod-saying-hello">
<p class="fw-medium fs-2xl">Hello! 👋</p>
<p>
Thank you for helping to keep <%= ApplicationConfig["COMMUNITY_NAME"] %> safe! ❤️
</p>
</div>
</div>
</div>

View file

@ -1,63 +1,26 @@
<% title "Moderate" %>
<% title "Mod Center" %>
<%= javascript_packs_with_chunks_tag "modCenter", defer: true %>
<div id="moderation-page"></div>
<% if current_user&.trusted %>
<% if @tag %>
<style>
.mod-index-hero, .mod-index-header-link--active {
background: <%= @tag.bg_color_hex %> !important;
color: <%= @tag.text_color_hex %> !important;
}
.mod-index-hero .mod-index-hero-data-pod {
border-color: <%= @tag.text_color_hex %> !important;
}
</style>
<% end %>
<% cache("mod-data-header-#{params[:tag]}", expires_in: 6.hours) do %>
<div class="mod-index-hero">
<h1><%= "#" + @tag.name.titleize if @tag %> Moderators</h1>
<h2>We build the <%= community_qualified_name %></h2>
<div class="crayons-layout crayons-layout--2-cols" id="mod-center">
<%= render "moderations/mod_sidebar_left" %>
<div class="mod-index-container articles-list crayons-layout__content">
<header class="mod-index-header">
<span class="tag-view fs-3xl fw-bold lh-base">
<%= @tag ? "##{@tag.name}" : "All topics" %>
</span>
</header>
<header class="mod-index-list-header lh-tight">
<h2 class="fs-xs fw-medium">Post</h2>
<h2 class="fs-xs fw-medium">Author</h2>
<h2 class="fs-xs fw-medium">Date</h2>
</header>
<main class="mod-index-list" id="mod-index-list" data-articles="<%= @articles %>">
</main>
</div>
<% end %>
<div class="mod-index-header">
<div class="mod-index-header-top-filters">
<a href="<%= request.path %>" class="<%= "mod-index-top-filter-link--active" if params[:state].blank? %>">Base Feed</a>
<a href="<%= request.path %>?state=new-authors" class="<%= "mod-index-top-filter-link--active" if params[:state] == "new-authors" %>">New Authors Only</a>
</div>
<% if current_user.moderator_for_tags.any? %>
<a href="/mod<%= "?state=new-authors" if params[:state] == "new-authors" %>" class="mod-index-header-link mod-index-header-link--<%= "active" if params[:tag].blank? %>">Site Wide</a>
<% current_user.moderator_for_tags.each do |tag| %>
<a href="/mod/<%= tag %><%= "?state=new-authors" if params[:state] == "new-authors" %>" class="mod-index-header-link mod-index-header-link--<%= "active" if params[:tag] == tag %>">#<%= tag %></a>
<% end %>
<% end %>
</div>
<div class="mod-index-list">
<% @articles.each_with_index do |article, i| %>
<div class="mod-index-row">
<a href="<%= article.path %>" class="mod-index-article-title"><%= article.title %></a>
<div class="mod-index-article-tagslist">
<% article.cached_tag_list_array.each do |tag| %>
<% tag_hash = tag_colors(tag) %>
<a class="mod-index-article-tag" href="/t/<%= tag %>" style="background-color:<%= tag_hash[:background] %>;color:<%= tag_hash[:color] %>">#<%= tag %></a>
<% end %>
</div>
<div class="mod-index-actions-and-details">
<button class="quick-mod-button cta" data-path="<%= article.path %>">Quick Mod</button>
<a href="/<%= article.cached_user.username %>"><img src="<%= ProfileImage.new(article.cached_user).get(width: 90) %>" /><%= article.cached_user.name %></a>
・ <%= article.readable_publish_date %>
<% if article.nth_published_by_author > 0 && article.nth_published_by_author < 4 %>
・ 👋 <strong><%= article.nth_published_by_author.ordinalize %> post by this user</strong>
<% end %>
</div>
<div class="mod-index-quick-mod" id="quick-mod-<%= i %>"></div>
</div>
<% end %>
</div>
<div class="mod-index-footer">
<a href="/community-moderation" class="mod-link">Community Moderation Guide</a>
<% if current_user.moderator_for_tags.any? %>
・ <a href="<%= tag_moderation_url %>" class="mod-link">Tag Moderation Guide</a>
<% end %>
</div>
<% else %>
<div class="container" style="margin-top: 90px;">
@ -66,24 +29,8 @@
<p>We periodically award some <%= community_name %> members with heightened privileges to help moderate the community.</p>
<p>Email <%= email_link %> if you'd like to be considered right away.</p>
<% unless user_signed_in? %>
<p><em>P.S. You are not currently signed in.</em></p>
<p><em>P.S. You are not currently signed in.</em></p>
<% end %>
</div>
</div>
<% end %>
<script>
var els = document.getElementsByClassName('quick-mod-button');
for (let i = 0; i < els.length; i += 1) {
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 id="quick-mod-article" src="' + path + '"></iframe><iframe class="actions-panel" src="' + path + '/actions_panel"></iframe></div>'
}
}
}
</script>

View file

@ -59,15 +59,16 @@ RSpec.describe "Moderations", type: :request do
end
it "grants access to /mod index with articles" do
create(:article, published: true)
article = create(:article, published: true)
get "/mod"
expect(response.body).to include("We build the")
expect(response.body).to include(article.title)
end
it "grants access to /mod/:tag index with articles" do
create(:article, published: true)
get "/mod/#{article.tags.first}"
expect(response.body).to include("#" + article.tags.first.name.titleize)
expect(response.body).to include("#" + article.tags.first.name)
expect(response.body).to include(article.title)
end
it "returns not found for inappropriate tags" do