Skip to content

Commit 4153f8d

Browse files
authored
Merge pull request #1756 from codidact/0valt/1351/subscriptions
Email subscription improvements
2 parents 4ece130 + 7e21641 commit 4153f8d

23 files changed

+418
-100
lines changed

app/assets/javascripts/categories.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ $(() => {
4343
$topic.val(union).trigger('change');
4444
});
4545

46-
$('.js-category-change-select').each((_i, el) => {
46+
$('.js-category-select').each((_i, el) => {
4747
const $tgt = $(el);
4848
$tgt.select2({
4949
ajax: {

app/assets/javascripts/posts.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,6 @@ $(() => {
114114
$postField.val($postField.val()?.toString().replace(placeholder, ''));
115115
});
116116

117-
$('.js-category-select').select2({
118-
tags: true
119-
});
120-
121117
/**
122118
* @typedef {{
123119
* body: string

app/assets/javascripts/subscriptions.js

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,79 @@
1-
$(() => {
1+
document.addEventListener('DOMContentLoaded', () => {
2+
/**
3+
* Extracts qualifier type from the field's dataset
4+
* @returns {string | null}
5+
*/
6+
const getQualifierType = () => {
7+
const field = document.querySelector('.js-sub-type-select');
8+
return field instanceof HTMLSelectElement ? field.value : null;
9+
};
10+
11+
/**
12+
* Is a given subscription type qualifiable?
13+
* @param {string} type subscription type
14+
* @returns {boolean}
15+
*/
16+
const isQualifiable = (type) => {
17+
return ['category', 'tag', 'user'].includes(type);
18+
};
19+
20+
/**
21+
* Synchronizes qualifier field with the given type
22+
* @param {string} type subscription type
23+
* @param {boolean} [clear] whether to clear qualifier value
24+
*/
25+
const syncQualifier = (type, clear = true) => {
26+
const field = document.querySelector('.js-sub-qualifier-select');
27+
const label = document.querySelector('.js-sub-qualifier-label');
28+
29+
if (field instanceof HTMLElement) {
30+
if (clear) {
31+
$(field).val(null).trigger('change');
32+
}
33+
34+
field.closest('.form-group')?.classList.toggle('hide', !isQualifiable(type));
35+
}
36+
37+
if (label instanceof HTMLElement) {
38+
label.textContent = type.slice(0, 1).toUpperCase() + type.slice(1).toLowerCase();
39+
}
40+
};
41+
42+
/**
43+
* Is a given element a subscription type select?
44+
* @param {Element} element
45+
* @returns {element is HTMLSelectElement}
46+
*/
47+
const isTypeSelect = (element) => {
48+
return element.matches('.js-sub-type-select');
49+
};
50+
51+
document.querySelectorAll('.js-sub-type-select, .js-sub-frequency-select').forEach((el) => {
52+
$(el).select2().on('change', ($event) => {
53+
if (isTypeSelect($event.target)) {
54+
syncQualifier($event.target.value);
55+
}
56+
});
57+
58+
if (isTypeSelect(el)) {
59+
syncQualifier(el.value, false);
60+
}
61+
});
62+
63+
$('.js-sub-qualifier-select').select2({
64+
ajax: {
65+
url: () => {
66+
const type = getQualifierType();
67+
return `/subscriptions/qualifiers?type=${type}`
68+
},
69+
headers: { 'Accept': 'application/json' },
70+
delay: 100,
71+
processResults: (results) => {
72+
return { results }
73+
},
74+
}
75+
});
76+
277
$('.js-enable-subscription').on('change', async (evt) => {
378
const $tgt = $(evt.target);
479
const $sub = $tgt.parents('details');

app/assets/javascripts/users.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,16 @@ $(() => {
7777
location.reload();
7878
}
7979
});
80+
81+
$('.js-user-select').each((_i, el) => {
82+
const $tgt = $(el);
83+
$tgt.select2({
84+
ajax: {
85+
url: '/users',
86+
headers: { 'Accept': 'application/json' },
87+
delay: 100,
88+
processResults: (data) => ({results: data.map((u) => ({id: u.id, text: u.username}))}),
89+
}
90+
});
91+
});
8092
});
Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
1-
// Place all the styles related to the Subscriptions controller here.
2-
// They will automatically be included in application.css.
3-
// You can use Sass (SCSS) here: http://sass-lang.com/
1+
@import 'variables';
2+
3+
.new_subscription {
4+
.select2-container {
5+
height: 37px;
6+
margin: 4px 0px;
7+
}
8+
9+
.select2-container {
10+
.selection {
11+
.select2-selection {
12+
border-color: $muted-graphic;
13+
height: 100%;
14+
padding: 4px 0;
15+
}
16+
17+
.select2-selection__arrow {
18+
top: 50%;
19+
translate: 0 -50%;
20+
}
21+
}
22+
}
23+
}

app/controllers/subscriptions_controller.rb

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
class SubscriptionsController < ApplicationController
22
before_action :authenticate_user!
33
before_action :stop_the_awful_troll
4-
helper_method :phrase_for
54

65
def new
7-
@phrasing = phrase_for params[:type]
8-
@subscription = Subscription.new
6+
@subscription = Subscription.new(new_sub_params)
97
end
108

119
def create
@@ -14,14 +12,41 @@ def create
1412
flash[:success] = 'Your subscription was saved successfully.'
1513
redirect_to params[:return_to].presence || root_path
1614
else
17-
render :error, status: :internal_server_error
15+
render :new, status: :bad_request
1816
end
1917
end
2018

2119
def index
2220
@subscriptions = current_user.subscriptions
2321
end
2422

23+
def qualifiers
24+
per_page = 20
25+
26+
@items = case params[:type]
27+
when 'category'
28+
Category.accessible_to(current_user)
29+
.order(sequence: :asc, id: :asc)
30+
when 'tag'
31+
Tag.order(name: :asc)
32+
when 'user'
33+
User.accessible_to(current_user)
34+
.joins(:community_user)
35+
.undeleted
36+
.where.not(community_users: { deleted: true })
37+
.order(username: :asc)
38+
end
39+
40+
@items = params[:q].present? ? @items&.search(params[:q]) : @items
41+
@items = @items&.paginate(page: params[:page], per_page: per_page).to_a
42+
43+
@items = @items.map do |item|
44+
{ id: item.is_a?(Tag) ? item.name : item.id, text: item.name }
45+
end
46+
47+
render json: @items
48+
end
49+
2550
def enable
2651
@subscription = Subscription.find params[:id]
2752
if current_user.admin? || current_user.id == @subscription.user_id
@@ -56,29 +81,12 @@ def destroy
5681
end
5782
end
5883

59-
protected
84+
private
6085

61-
def phrase_for(type, qualifier = nil)
62-
case type
63-
when 'all'
64-
'all new questions'
65-
when 'tag'
66-
"new questions in the tag '#{Tag.find_by(name: qualifier || params[:qualifier])&.name}'"
67-
when 'user'
68-
"new questions by the user '#{User.find_by(id: qualifier || params[:qualifier])&.username}'"
69-
when 'interesting'
70-
'new questions classed as interesting'
71-
when 'category'
72-
"new questions in the category '#{Category.find_by(id: qualifier || params[:qualifier])&.name}'"
73-
when 'moderators'
74-
'announcements and newsletters for moderators'
75-
else
76-
'nothing, apparently. How did you get here, again?'
77-
end
86+
def new_sub_params
87+
params.permit(:type, :qualifier, :frequency, :name)
7888
end
7989

80-
private
81-
8290
def sub_params
8391
params.require(:subscription).permit(:type, :qualifier, :frequency, :name)
8492
end

app/controllers/users_controller.rb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ def index
2929
.paginate(page: params[:page], per_page: 48)
3030

3131
@post_counts = Post.where(user_id: @users.pluck(:id).uniq).group(:user_id).count
32+
33+
respond_to do |format|
34+
format.html
35+
format.json do
36+
render json: @users
37+
end
38+
end
3239
end
3340

3441
def show
@@ -682,11 +689,9 @@ def set_user
682689
end
683690

684691
def user_scope
685-
if current_user&.at_least_moderator?
686-
User.all
687-
else
688-
User.undeleted
689-
end.joins(:community_user).includes(:community_user, :avatar_attachment)
692+
User.accessible_to(current_user)
693+
.joins(:community_user)
694+
.includes(:community_user, :avatar_attachment)
690695
end
691696

692697
def check_deleted
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,36 @@
11
module SubscriptionsHelper
2+
# Generates <select> options for available subscription frequences
3+
# @return [Array(String, Integer)]
4+
def frequency_choice
5+
[
6+
['Every day', 1],
7+
['Every week', 7],
8+
['Every month', 30],
9+
['Every quarter', 90]
10+
]
11+
end
12+
13+
# Gets human-readable representation of a given subscription type
14+
# @param type [String] subscription type
15+
# @return [String]
16+
def phrase_for(type)
17+
phrase_map = {
18+
all: 'all new questions',
19+
tag: 'new questions with the tag',
20+
user: 'new questions by the user',
21+
interesting: 'new questions classed as interesting',
22+
category: 'new questions in the category',
23+
moderators: 'announcements and newsletters for moderators'
24+
}
25+
26+
phrase_map[type.to_sym] || 'nothing, apparently. How did you get here, again?'
27+
end
28+
29+
# Generates <select> options for available subscription types for a given user
30+
# @param user [User] user to perform access control checks for
31+
# @return [Array(String, String)]
32+
def type_choice_for(user)
33+
Subscription.types_accessible_to(user)
34+
.map { |type| [phrase_for(type).capitalize, type] }
35+
end
236
end

app/models/category.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,8 @@ def self.by_id(id)
6767
end
6868
categories[id]
6969
end
70+
71+
def self.search(term)
72+
where('name LIKE ?', "%#{sanitize_sql_like(term)}%")
73+
end
7074
end

app/models/subscription.rb

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
class Subscription < ApplicationRecord
2-
self.inheritance_column = 'sti_type'
3-
42
include CommunityRelated
53
include Timestamped
64

5+
self.inheritance_column = 'sti_type'
6+
7+
BASE_TYPES = ['all', 'tag', 'user', 'interesting', 'category'].freeze
8+
MOD_ONLY_TYPES = ['moderators'].freeze
9+
TYPES = (BASE_TYPES + MOD_ONLY_TYPES).freeze
10+
QUALIFIED_TYPES = ['category', 'tag', 'user'].freeze
11+
712
belongs_to :user
813

9-
validates :type, presence: true, inclusion: ['all', 'tag', 'user', 'interesting', 'category', 'moderators']
14+
validates :type, presence: true, inclusion: TYPES
1015
validates :frequency, numericality: { minimum: 1, maximum: 90 }
1116

1217
validate :qualifier_presence
1318

19+
# Gets a list of subscription types available to a given user
20+
# @param user [User] user to check type access for
21+
# @return [Array<String>] list of available types
22+
def self.types_accessible_to(user)
23+
user.at_least_moderator? ? TYPES : BASE_TYPES
24+
end
25+
1426
def questions
1527
case type
1628
when 'all'
@@ -36,10 +48,39 @@ def questions
3648
end&.order(created_at: :desc)&.limit(25)
3749
end
3850

51+
# Is the subscription's type qualified (bound to an entity)?
52+
# @param type [String] type to check
53+
# @return [Boolean] check result
54+
def qualified?
55+
QUALIFIED_TYPES.include?(type)
56+
end
57+
58+
# Gets entity bound to the subscription through qualifier, if any
59+
# @return [Category, Tag, User, nil]
60+
def qualifier_entity
61+
if qualified? && qualifier.present?
62+
model = type.singularize.classify.constantize
63+
tag? ? model.find_by(name: qualifier) : model.find(qualifier)
64+
end
65+
end
66+
67+
# Gets name of the entity bound to the subscription through qualifier, if any
68+
# @return [String]
69+
def qualifier_name
70+
qualifier_entity&.name || qualifier
71+
end
72+
73+
# Predicates for each of the available type (f.e., user?)
74+
TYPES.each do |type_name|
75+
define_method "#{type_name}?" do
76+
type == type_name
77+
end
78+
end
79+
3980
private
4081

4182
def qualifier_presence
42-
return unless ['tag', 'user', 'category'].include? type
83+
return unless qualified?
4384

4485
if type == 'tag' && (qualifier.blank? || Tag.find_by(name: qualifier).nil?)
4586
errors.add(:qualifier, 'must provide a valid tag name for tag subscriptions')

0 commit comments

Comments
 (0)