Allow archiving of posts (#2798)

* This commit adds the "archived" attribute to the Article model, which will
enable users to automatically filter archived articles/drafts from appearing
on their dashboard. It also adds an ellipsis menu with a link to archive the
article. I've also moved the Mute Notifications button into the ellipsis menu
as well.

* Add ellipsis menu and make it work

* Fix logic error

* Hide ellipsis menus when you click elsewhere on the body

* Extract function to remove duplication

* Ensure that menu is hidden when the ellipsis button is clicked on an open menu

* Ensure that archiving post button works and that 'archived' param is accepted by the controller

* Enable link to toggle showing/hiding archived articles

* Make 'mute' and 'archive' buttons in ellipsis dropdown list work

* Refactor to make Code Climate happy

* Minor JS refactors
This commit is contained in:
Dana Scheider 2019-05-14 03:40:52 +08:00 committed by Ben Halpern
parent ab344801db
commit b2a8f69f83
12 changed files with 290 additions and 5 deletions

View file

@ -42,6 +42,8 @@ function callInitalizers(){
initializeTimeFixer();
initializeDashboardSort();
initializePWAFunctionality();
initializeEllipsisMenu();
initializeArchivedPostFilter();
if (!initializeLiveArticle.called){
initializeLiveArticle();

View file

@ -0,0 +1,37 @@
function archivedPosts() {
return document.getElementsByClassName('single-article-archived');
}
function showArchivedPosts() {
var posts = archivedPosts();
for (var i = 0; i < posts.length; i += 1) {
posts[i].classList.remove('hidden');
}
}
function hideArchivedPosts() {
var posts = archivedPosts();
for (var i = 0; i < posts.length; i += 1) {
posts[i].classList.add('hidden');
}
}
function toggleArchivedPosts(e) {
var link = e.target;
if (link.innerHTML.match(/Show/)) {
link.innerHTML = 'Hide Archived';
showArchivedPosts();
} else {
link.innerHTML = 'Show Archived';
hideArchivedPosts();
}
}
function initializeArchivedPostFilter() {
var link = document.getElementById('toggleArchivedLink');
link.addEventListener('click', toggleArchivedPosts);
}

View file

@ -0,0 +1,150 @@
// SUBMITTING FORM //
function getFormValues(form) {
var articleId = form.action.match(/\/(\d+)$/)[1];
var inputs = form.querySelectorAll('input');
var formData = { id: articleId, article: {} };
for (var i = 0; i < inputs.length; i += 1) {
var input = inputs[i];
var name = input.getAttribute('name');
var value = input.getAttribute('value');
if (name.match(/\[(.*)\]/)) {
var key = name.match(/\[(.*)\]$/)[1];
formData.article[key] = value;
} else {
formData[name] = value;
}
}
return formData;
}
function toggleArchived(article, needsArchived) {
if (needsArchived === 'true') {
article.classList.add('single-article-archived', 'hidden');
} else {
article.classList.remove('single-article-archived');
}
}
function toggleNotifications(submit, action) {
if (action === 'Mute Notifications') {
submit.setAttribute('value', 'Receive Notifications');
} else {
submit.setAttribute('value', 'Mute Notifications');
}
}
function onXhrSuccess(form, article, values) {
if (values.article.archived) {
toggleArchived(article, values.article.archived);
} else {
var submit = form.querySelector('input[type="submit"]');
var submitValue = submit.getAttribute('value');
toggleNotifications(submit, submitValue);
}
article.querySelector('ul.ellipsis-menu').classList.add('hidden');
}
function handleFormSubmit(e) {
e.preventDefault();
e.stopPropagation();
var form = e.target;
var values = getFormValues(form);
var data = JSON.stringify(values);
var xhr = new XMLHttpRequest();
xhr.open('PATCH', form.action);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(data);
xhr.onload = function() {
var article = form.closest('div.single-article');
if (xhr.status === 200) {
onXhrSuccess(form, article, values);
} else {
article.querySelector('.dashboard-meta-details').innerHTML =
'Failed to update article.';
}
};
}
function initializeFormSubmit() {
var forms = document.querySelectorAll('ul.ellipsis-menu > li > form');
for (var i = 0; i < forms.length; i += 1) {
forms[i].addEventListener('submit', handleFormSubmit);
}
}
// TOGGLING MENU //
function getMenu(el) {
var parentDiv = el.closest('div.ellipsis-menu');
var menu = parentDiv.querySelector('ul.ellipsis-menu');
return menu;
}
function hideIfNotAlreadyHidden(menu) {
if (!menu.classList.contains('hidden')) {
menu.classList.add('hidden');
}
}
function hideAllEllipsisMenusExcept(menu) {
var menus = document.querySelectorAll('ul.ellipsis-menu');
for (var i = 0; i < menus.length; i += 1) {
if (menus[i] !== menu) {
hideIfNotAlreadyHidden(menus[i]);
}
}
}
function hideEllipsisMenus(e) {
if (!e.target.closest('div.ellipsis-menu')) {
var menus = document.querySelectorAll('ul.ellipsis-menu');
for (var i = 0; i < menus.length; i += 1) {
hideIfNotAlreadyHidden(menus[i]);
}
}
}
function toggleEllipsisMenu(e) {
var menu = getMenu(e.target);
// Make sure other ellipsis menus close when a new one
// is opened
hideAllEllipsisMenusExcept(menu);
if (menu.classList.contains('hidden')) {
menu.classList.remove('hidden');
} else {
menu.classList.add('hidden');
}
}
function initializeEllipsisMenuToggle() {
var buttons = document.getElementsByClassName('ellipsis-menu-btn');
for (var i = 0; i < buttons.length; i += 1) {
buttons[i].addEventListener('click', toggleEllipsisMenu);
}
// Hide ellipsis menus when you click outside of the ellipsis menu parent div
document
.getElementsByTagName('BODY')[0]
.addEventListener('click', hideEllipsisMenus);
}
function initializeEllipsisMenu() {
initializeEllipsisMenuToggle();
initializeFormSubmit();
}

View file

@ -318,6 +318,59 @@
}
}
& div.ellipsis-menu {
float: right;
position: relative;
& button.ellipsis-menu-btn {
display: block;
float: right;
margin-top: 1em;
border-radius: 15px;
&:hover {
background-color: #eee;
}
}
& ul.ellipsis-menu {
position: absolute;
right: 0;
display: block;
background-color: white;
padding-left: 0;
border-style: solid;
border-color: #ccc;
border-radius: 5px;
border-width: 1px;
margin-top: 2.75em;
&.hidden {
display: none;
}
& hr {
margin: 0;
color: #ccc;
background-color: #ccc;
height: 1px;
border: 0;
}
& li.ellipsis-menu-item {
list-style: none;
& form * {
background-color: transparent;
}
&:hover {
background-color: #eee;
}
}
}
}
.dashboard-collection-org-details {
.dashboard-top-pill {
@include themeable(

View file

@ -5,6 +5,9 @@ class ArticleMutesController < ApplicationController
@article = Article.find_by(id: params[:id])
authorize @article
@article.update(receive_notifications: permitted_attributes(@article)[:receive_notifications])
redirect_to "/dashboard"
respond_to do |format|
format.json { head :ok }
format.html { redirect_to "/dashboard" }
end
end
end

View file

@ -145,9 +145,16 @@ class ArticlesController < ApplicationController
Notification.remove_all_without_delay(notifiable_id: @article.id, notifiable_type: "Article", action: "Published")
path = "/#{@article.username}/#{@article.slug}?preview=#{@article.password}"
end
redirect_to(params[:destination] || path)
respond_to do |format|
format.json { head :ok }
format.html { redirect_to(params[:destination] || path) }
end
else
render :edit
respond_to do |format|
format.html { redirect_to :edit }
format.json { head :unprocessable_entity }
end
end
end

View file

@ -26,7 +26,8 @@ class ArticlePolicy < ApplicationPolicy
def permitted_attributes
%i[title body_html body_markdown main_image published canonical_url
description allow_small_edits allow_big_edits tag_list publish_under_org
video video_code video_source_url video_thumbnail_url receive_notifications]
video video_code video_source_url video_thumbnail_url receive_notifications
archived]
end
private

View file

@ -1,4 +1,4 @@
<div class="single-article <%= "single-article-unpublished" unless article.published %>">
<div class="single-article <%= "single-article-unpublished" unless article.published %> <%= "single-article-archived hidden" if article.archived %>">
<div class="dashboard-collection-org-details">
<% if article.organization_id %>
<span class="dashboard-top-pill"><%= article.organization&.name %></span>
@ -7,6 +7,26 @@
<span class="dashboard-top-pill"><strong>Series:</strong> <%= article.series %></span>
<% end %>
</div>
<div class="ellipsis-menu">
<button class="ellipsis-menu-btn"><i class="material-icons">more_horiz</i></button>
<ul class="ellipsis-menu hidden">
<li class="ellipsis-menu-item">
<%= form_for(article, url: "/article_mutes/#{article.id}", method: :patch, authenticity_token: true, html: { "data-type": "json" }) do |f| %>
<%= f.hidden_field :receive_notifications, value: !article.receive_notifications %>
<%= f.submit article.receive_notifications ? "Mute Notifications" : "Receive Notifications" %>
<% end %>
</li>
<hr />
<li class="ellipsis-menu-item">
<%= form_for(article, method: :patch, remote: true, authenticity_token: true, html: { "data-type": "json" }) do |f| %>
<%= f.hidden_field :archived, value: !article.archived %>
<%= f.submit article.archived ? "Unarchive Article" : "Archive Article" %>
<% end %>
</li>
</ul>
</div>
<a href="<%= article.current_state_path %>"><h2><%= article.title %></h2></a>
<div class="dashboard-meta-details">
<% if article.published %>
@ -29,6 +49,8 @@
<a href="<%= article.path %>/delete_confirm" data-no-instant class="cta pill black">DELETE</a>
<% elsif article.published %>
<a href="<%= article.path %>/manage" class="cta pill black">MANAGE</a>
<% else %>
<a href="<%= article.path %>/delete_confirm" data-no-instant class="cta pill black">DELETE</a>
<% end %>
<% if article.published %>
<span id="pageviews-<%= article.id %>" class="cta pill dashboard-pageviews-indicator" data-analytics-pageviews data-article-id="<%= article.id %>">

View file

@ -29,6 +29,9 @@
<% end %>
<%= select_tag "dashhboard_sort", options_for_select(sort_options, params[:sort]) %>
<% if @articles.any? {|article| article.archived } %>
<%= link_to "Show archived", "javascript:;", id: "toggleArchivedLink" %>
<% end %>
</div>
<% @articles.each do |article| %>
<%= render "dashboard_article", article: article, org_admin: false, manage_view: false %>

View file

@ -55,6 +55,7 @@
<%= yield(:page_meta) %>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<%= favicon_link_tag %>
<%= stylesheet_link_tag "https://fonts.googleapis.com/icon?family=Material+Icons" %>
<link rel="apple-touch-icon" href="<%= asset_path "apple-icon.png" %>">
<link rel="apple-touch-icon" sizes="152x152" href="<%= asset_path "apple-icon-152x152.png" %>">
<link rel="apple-touch-icon" sizes="180x180" href="<%= asset_path "apple-icon-180x180.png" %>">

View file

@ -0,0 +1,5 @@
class AddArchivedToArticles < ActiveRecord::Migration[5.2]
def change
add_column :articles, :archived, :boolean, default: false
end
end

View file

@ -52,6 +52,7 @@ ActiveRecord::Schema.define(version: 2019_05_04_131412) do
t.float "amount_due", default: 0.0
t.float "amount_paid", default: 0.0
t.boolean "approved", default: false
t.boolean "archived", default: false
t.boolean "automatically_renew", default: false
t.text "body_html"
t.text "body_markdown"