From f6dd61327e90052c2af8df6743f341e5bd8ebb33 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 13:28:16 +0800 Subject: [PATCH 001/968] Create views for filters --- app/views/search/_filters.html.erb | 13 +++++++++++++ app/views/search/_widget.html.erb | 12 ++++++++++++ app/views/search/search.html.erb | 12 +----------- 3 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 app/views/search/_filters.html.erb create mode 100644 app/views/search/_widget.html.erb diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb new file mode 100644 index 000000000..ced2f992d --- /dev/null +++ b/app/views/search/_filters.html.erb @@ -0,0 +1,13 @@ +
+

Filter

+
+
+ <%= label_tag :filter_score_min, 'Min Score', class: "form-element" %> + <%= number_field_tag :filter_score_min, nil, min: 0, max: 1, step: 0.01, class: 'form-element' %> +
+
+ <%= label_tag :filter_score_max, 'Max Score', class: "form-element" %> + <%= number_field_tag :filter_score_max, nil, min: 0, max: 1, step: 0.01, class: 'form-element' %> +
+
+
diff --git a/app/views/search/_widget.html.erb b/app/views/search/_widget.html.erb new file mode 100644 index 000000000..57f199d91 --- /dev/null +++ b/app/views/search/_widget.html.erb @@ -0,0 +1,12 @@ +<%= form_tag search_path, method: :get do %> +
+
+ <%= label_tag :search, 'Search term', class: "form-element" %> + <%= text_field_tag :search, params[:search], class: 'form-element' %> +
+
+ <%= submit_tag 'Search', class: 'button is-medium is-outlined', name: nil %> +
+
+ <%= render 'filters' %> +<% end %> diff --git a/app/views/search/search.html.erb b/app/views/search/search.html.erb index 3e91baa9c..210655365 100644 --- a/app/views/search/search.html.erb +++ b/app/views/search/search.html.erb @@ -2,17 +2,7 @@

Search

-<%= form_tag search_path, method: :get do %> -
-
- <%= label_tag :search, 'Search term', class: "form-element" %> - <%= text_field_tag :search, params[:search], class: 'form-element' %> -
-
- <%= submit_tag 'Search', class: 'button is-medium is-outlined', name: nil %> -
-
-<% end %> +<%= render 'widget' %>
From ceade96775aeb2f2987b4cdb2fdbb843d2685fe5 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 14:27:44 +0800 Subject: [PATCH 002/968] Refactor search and make filter functional --- app/controllers/search_controller.rb | 16 +---------- app/helpers/search_helper.rb | 41 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index f2e6673ef..27caba47a 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,20 +1,6 @@ class SearchController < ApplicationController def search - @posts = if params[:search].present? - search_data = helpers.parse_search(params[:search]) - posts = (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted) - .qa_only.list_includes - posts = helpers.qualifiers_to_sql(search_data[:qualifiers], posts) - posts = posts.paginate(page: params[:page], per_page: 25) - - if search_data[:search].present? - posts.search(search_data[:search]).user_sort({ term: params[:sort], default: :search_score }, - relevance: :search_score, score: :score, age: :created_at) - else - posts.user_sort({ term: params[:sort], default: :score }, - score: :score, age: :created_at) - end - end + @posts = helpers.search_posts @count = begin @posts&.count rescue diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index f5786839a..174ee1ec4 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,4 +1,45 @@ module SearchHelper + def search_posts + # Check permissions + posts = (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted) + .qa_only.list_includes + + # Filter based on search string qualifiers + if params[:search].present? + search_data = parse_search(params[:search]) + posts = qualifiers_to_sql(search_data[:qualifiers], posts) + end + + posts = filters_to_sql(posts) + + posts = posts.paginate(page: params[:page], per_page: 25) + + if params[:search].present? && search_data[:search].present? + posts.search(search_data[:search]).user_sort({ term: params[:sort], default: :search_score }, + relevance: :search_score, score: :score, age: :created_at) + else + posts.user_sort({ term: params[:sort], default: :score }, + score: :score, age: :created_at) + end + end + + def filters_to_sql(query) + valid_value = { + date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, + numeric: /^[\d.]+$/ + } + + if params[:filter_score_min]&.match?(valid_value[:numeric]) + query = query.where('score >= ?', params[:filter_score_min].to_f) + end + + if params[:filter_score_max]&.match?(valid_value[:numeric]) + query = query.where('score <= ?', params[:filter_score_max].to_f) + end + + query + end + def parse_search(raw_search) qualifiers_regex = /([\w\-_]+(? Date: Tue, 13 Sep 2022 14:29:55 +0800 Subject: [PATCH 003/968] Autofill filter fields --- app/views/search/_filters.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index ced2f992d..c2c8adf1c 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -3,11 +3,11 @@
<%= label_tag :filter_score_min, 'Min Score', class: "form-element" %> - <%= number_field_tag :filter_score_min, nil, min: 0, max: 1, step: 0.01, class: 'form-element' %> + <%= number_field_tag :filter_score_min, params[:filter_score_min], min: 0, max: 1, step: 0.01, class: 'form-element' %>
<%= label_tag :filter_score_max, 'Max Score', class: "form-element" %> - <%= number_field_tag :filter_score_max, nil, min: 0, max: 1, step: 0.01, class: 'form-element' %> + <%= number_field_tag :filter_score_max, params[:filter_score_max], min: 0, max: 1, step: 0.01, class: 'form-element' %>
From 9364b7e2e158cc01b364b449c7f58c2e49627c3b Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 14:38:26 +0800 Subject: [PATCH 004/968] Add filter widget to sidebar --- app/views/layouts/_sidebar.html.erb | 9 ++++++++- app/views/search/_filters.html.erb | 1 - app/views/search/_widget.html.erb | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 33a713a42..6da767cc4 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -86,7 +86,14 @@
<% end %> - +
+
+ Filters +
+
+ <%= render 'search/filters' %> +
+
<% unless @community.is_fake %> <% if user_signed_in? %>
diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index c2c8adf1c..6e373172f 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -1,5 +1,4 @@
-

Filter

<%= label_tag :filter_score_min, 'Min Score', class: "form-element" %> diff --git a/app/views/search/_widget.html.erb b/app/views/search/_widget.html.erb index 57f199d91..07ecb0582 100644 --- a/app/views/search/_widget.html.erb +++ b/app/views/search/_widget.html.erb @@ -8,5 +8,6 @@ <%= submit_tag 'Search', class: 'button is-medium is-outlined', name: nil %>
+

Filters

<%= render 'filters' %> <% end %> From d4e0e3786e63df4b9239702871acb7ec7a400755 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 14:45:29 +0800 Subject: [PATCH 005/968] Add form to filter widget --- app/views/layouts/_sidebar.html.erb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 6da767cc4..db28f3320 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -91,7 +91,12 @@ Filters
- <%= render 'search/filters' %> + <%= form_tag request.original_url, method: :get do %> +
+ <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> +
+ <%= render 'search/filters' %> + <% end %>
<% unless @community.is_fake %> From 7d2bcc4d6f7c7d04c713cc47cf5eb8c6572de439 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 14:51:59 +0800 Subject: [PATCH 006/968] Make filters work with categories --- app/controllers/categories_controller.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index b8076e366..f54ebe390 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -161,8 +161,9 @@ def set_list_posts native: Arel.sql('att_source IS NULL DESC, last_activity DESC') } sort_param = sort_params[params[:sort]&.to_sym] || { last_activity: :desc } @posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types) - .includes(:post_type, :tags).list_includes.paginate(page: params[:page], per_page: 50) - .order(sort_param) + .includes(:post_type, :tags).list_includes + @posts = helpers.filters_to_sql @posts + @posts = @posts.paginate(page: params[:page], per_page: 50).order(sort_param) end def update_last_visit(category) From cbc5bf7baef5f67e5f5d449891213afd86c08ba2 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 15:02:37 +0800 Subject: [PATCH 007/968] Move apply button to bottom --- app/views/layouts/_sidebar.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index db28f3320..715412ad1 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -92,10 +92,10 @@
<%= form_tag request.original_url, method: :get do %> + <%= render 'search/filters' %>
<%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %>
- <%= render 'search/filters' %> <% end %>
From 084ed3e4bf0cb4ce4488488d1051bd9f791930a3 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 16:12:53 +0800 Subject: [PATCH 008/968] Make predefined filters work --- app/assets/javascripts/filters.js | 31 ++++++++++++++++++++++++++++++ app/views/search/_filters.html.erb | 12 ++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/filters.js diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js new file mode 100644 index 000000000..caabd4f41 --- /dev/null +++ b/app/assets/javascripts/filters.js @@ -0,0 +1,31 @@ +$(() => { + const predefined = { + 'Positive': { + 'score-min': 0.5, + 'score-max': 1 + } + }; + + $('.js-filter-select').each((i, el) => { + const $tgt = $(el); + const $form = $tgt.closest('form'); + + $tgt.select2({ + data: Object.keys(predefined), + }).on('select2:select', evt => { + const filterName = evt.params.data.id; + const preset = predefined[filterName]; + + for (const [name, value] of Object.entries(preset)) { + $form.find(`.filter-${name}`).val(value); + } + }); + + // Clear the preset when the user enters in a filter manually + $form.find('.form--filter').each((i, filter) => { + $(filter).on('change', _ => { + $tgt.val(null).trigger('change'); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 6e373172f..1873e8759 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -1,12 +1,20 @@
+
+ <%= label_tag :predefined_filter, 'Predefined Filters', class: "form-element" %> + <%= select_tag :predefined_filter, options_for_select([]), + include_blank: true, class: "form-element js-filter-select", + data: { placeholder: "" } %> +
<%= label_tag :filter_score_min, 'Min Score', class: "form-element" %> - <%= number_field_tag :filter_score_min, params[:filter_score_min], min: 0, max: 1, step: 0.01, class: 'form-element' %> + <%= number_field_tag :filter_score_min, params[:filter_score_min], + min: 0, max: 1, step: 0.01, class: 'form-element form--filter filter-score-min' %>
<%= label_tag :filter_score_max, 'Max Score', class: "form-element" %> - <%= number_field_tag :filter_score_max, params[:filter_score_max], min: 0, max: 1, step: 0.01, class: 'form-element' %> + <%= number_field_tag :filter_score_max, params[:filter_score_max], + min: 0, max: 1, step: 0.01, class: 'form-element form--filter filter-score-max' %>
From e3194466e792992d18c9400e52da93e03b65dfd9 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 16:40:37 +0800 Subject: [PATCH 009/968] Fix duplicate id issue --- app/views/search/_filters.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 1873e8759..4130c94c9 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -2,7 +2,7 @@
<%= label_tag :predefined_filter, 'Predefined Filters', class: "form-element" %> <%= select_tag :predefined_filter, options_for_select([]), - include_blank: true, class: "form-element js-filter-select", + include_blank: true, class: "form-element js-filter-select", id: nil, data: { placeholder: "" } %>
From 928d880577e755a5dd4829ef429825ca028f3b92 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 18:11:53 +0800 Subject: [PATCH 010/968] Unify filter and search qualifiers --- app/controllers/categories_controller.rb | 3 +- app/helpers/search_helper.rb | 91 ++++++++++++++++-------- 2 files changed, 64 insertions(+), 30 deletions(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index f54ebe390..3752ede60 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -162,7 +162,8 @@ def set_list_posts sort_param = sort_params[params[:sort]&.to_sym] || { last_activity: :desc } @posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types) .includes(:post_type, :tags).list_includes - @posts = helpers.filters_to_sql @posts + filter_qualifiers = helpers.filters_to_qualifiers + @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts) @posts = @posts.paginate(page: params[:page], per_page: 50).order(sort_param) end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 174ee1ec4..1d86ebe9f 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -4,17 +4,20 @@ def search_posts posts = (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted) .qa_only.list_includes + qualifiers = filters_to_qualifiers + search_string = params[:search] + # Filter based on search string qualifiers - if params[:search].present? - search_data = parse_search(params[:search]) - posts = qualifiers_to_sql(search_data[:qualifiers], posts) + if search_string.present? + search_data = parse_search(search_string) + qualifiers += parse_qualifier_strings search_data[:qualifiers] + search_string = search_data[:search] end - posts = filters_to_sql(posts) - + posts = qualifiers_to_sql(qualifiers, posts) posts = posts.paginate(page: params[:page], per_page: 25) - if params[:search].present? && search_data[:search].present? + if search_string.present? posts.search(search_data[:search]).user_sort({ term: params[:sort], default: :search_score }, relevance: :search_score, score: :score, age: :created_at) else @@ -23,21 +26,23 @@ def search_posts end end - def filters_to_sql(query) + def filters_to_qualifiers valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, numeric: /^[\d.]+$/ } + filter_qualifiers = [] + if params[:filter_score_min]&.match?(valid_value[:numeric]) - query = query.where('score >= ?', params[:filter_score_min].to_f) + filter_qualifiers.append({ param: :score, operator: '>=', value: params[:filter_score_min].to_f }) end if params[:filter_score_max]&.match?(valid_value[:numeric]) - query = query.where('score <= ?', params[:filter_score_max].to_f) + filter_qualifiers.append({ param: :score, operator: '<=', value: params[:filter_score_max].to_f }) end - query + filter_qualifiers end def parse_search(raw_search) @@ -52,13 +57,13 @@ def parse_search(raw_search) end # rubocop:disable Metrics/CyclomaticComplexity - def qualifiers_to_sql(qualifiers, query) + def parse_qualifier_strings(qualifiers) valid_value = { date: /^[<>=]{0,2}[\d.]+(?:s|m|h|d|w|mo|y)?$/, numeric: /^[<>=]{0,2}[\d.]+$/ } - qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength + qualifiers.map do |qualifier| # rubocop:disable Metrics/BlockLength splat = qualifier.split ':' parameter = splat[0] value = splat[1] @@ -68,58 +73,86 @@ def qualifiers_to_sql(qualifiers, query) next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("score #{operator.presence || '='} ?", val.to_f) + { param: :score, operator: operator.presence || '=', value: val.to_f } when 'created' next unless value.match?(valid_value[:date]) operator, val, timeframe = date_value_sql value - query = query.where("created_at #{operator.presence || '='} DATE_SUB(CURRENT_TIMESTAMP, " \ - "INTERVAL ? #{timeframe})", - val.to_i) + { param: :created, operator: operator.presence || '=', timeframe: timeframe, value: val.to_i } when 'user' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("user_id #{operator.presence || '='} ?", val.to_i) + { param: :user, operator: operator.presence || '=', user_id: val.to_i } when 'upvotes' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("upvotes #{operator.presence || '='} ?", val.to_i) + { param: :upvotes, operator: operator.presence || '=', value: val.to_i } when 'downvotes' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("downvotes #{operator.presence || '='} ?", val.to_i) + { param: :downvotes, operator: operator.presence || '=', value: val.to_i } when 'votes' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("(upvotes - downvotes) #{operator.presence || '='}", val.to_i) + { param: :net_votes, operator: operator.presence || '=', value: val.to_i } when 'tag' - query = query.where(posts: { id: PostsTag.where(tag_id: Tag.where(name: value).select(:id)).select(:post_id) }) + { param: :include_tag, tag_id: Tag.where(name: value).select(:id) } when '-tag' - query = query.where.not(posts: { id: PostsTag.where(tag_id: Tag.where(name: value).select(:id)) - .select(:post_id) }) + { param: :exclude_tag, tag_id: Tag.where(name: value).select(:id) } when 'category' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - trust_level = current_user&.trust_level || 0 - allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) - query = query.where("category_id #{operator.presence || '='} ?", val.to_i) - .where(category_id: allowed_categories) + { param: :category, operator: operator.presence || '=', category_id: val.to_i } when 'post_type' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value - query = query.where("post_type_id #{operator.presence || '='} ?", val.to_i) + { param: :post_type, operator: operator.presence || '=', post_type_id: val.to_i } when 'answers' next unless value.match?(valid_value[:numeric]) operator, val = numeric_value_sql value + { param: :answers, operator: operator.presence || '=', value: val.to_i } + end + end + end + + def qualifiers_to_sql(qualifiers, query) + qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength + case qualifier[:param] + when :score + query = query.where("score #{qualifier[:operator]} ?", qualifier[:value]) + when :created + query = query.where("created_at #{qualifier[:operator]} DATE_SUB(CURRENT_TIMESTAMP, " \ + "INTERVAL ? #{qualifier[:timeframe]})", + qualifier[:value]) + when :user + query = query.where("user_id #{qualifier[:operator]} ?", qualifier[:user_id]) + when :upvotes + query = query.where("upvote_count #{qualifier[:operator]} ?", qualifier[:value]) + when :downvotes + query = query.where("downvote_count #{qualifier[:operator]} ?", qualifier[:value]) + when :net_votes + query = query.where("(upvote_count - downvote_count) #{qualifier[:operator]} ?", qualifier[:value]) + when :include_tag + query = query.where(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) }) + when :exclude_tag + query = query.where.not(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) }) + when :category + trust_level = current_user&.trust_level || 0 + allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) + query = query.where("category_id #{qualifier[:operator]} ?", qualifier[:category_id]) + .where(category_id: allowed_categories) + when :post_type + query = query.where("post_type_id #{qualifier[:operator]} ?", qualifier[:post_type_id]) + when :answers post_types_with_answers = PostType.where(has_answers: true) - query = query.where("answer_count #{operator.presence || '='} ?", val.to_i) + query = query.where("answer_count #{qualifier[:operator]} ?", qualifier[:value]) .where(post_type_id: post_types_with_answers) end end From 0b8bfc528c52bce9b8d68ceee38ae6071b8fc695 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 18:26:49 +0800 Subject: [PATCH 011/968] Add answer count filter --- app/helpers/search_helper.rb | 8 ++++++++ app/views/search/_filters.html.erb | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 1d86ebe9f..d53ad0864 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -42,6 +42,14 @@ def filters_to_qualifiers filter_qualifiers.append({ param: :score, operator: '<=', value: params[:filter_score_max].to_f }) end + if params[:filter_answers_min]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :answers, operator: '>=', value: params[:filter_answers_min].to_i }) + end + + if params[:filter_answers_max]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :answers, operator: '<=', value: params[:filter_answers_max].to_i }) + end + filter_qualifiers end diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 4130c94c9..e05fd6333 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -17,4 +17,16 @@ min: 0, max: 1, step: 0.01, class: 'form-element form--filter filter-score-max' %>
+
+
+ <%= label_tag :filter_answers_min, 'Min Answers', class: "form-element" %> + <%= number_field_tag :filter_answers_min, params[:filter_answers_min], + min: 0, step: 1, class: 'form-element form--filter filter-answers-min' %> +
+
+ <%= label_tag :filter_answers_max, 'Max Answers', class: "form-element" %> + <%= number_field_tag :filter_answers_max, params[:filter_answers_max], + min: 0, step: 1, class: 'form-element form--filter filter-answers-max' %> +
+
From 5aa9ef71f4aa83f8801aae22aaeb46deb64e6d0d Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 18:33:54 +0800 Subject: [PATCH 012/968] Formatting --- app/assets/javascripts/filters.js | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index caabd4f41..7d3d320d6 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -1,31 +1,31 @@ $(() => { - const predefined = { - 'Positive': { - 'score-min': 0.5, - 'score-max': 1 - } - }; + const predefined = { + 'Positive': { + 'score-min': 0.5, + 'score-max': 1 + } + }; - $('.js-filter-select').each((i, el) => { - const $tgt = $(el); + $('.js-filter-select').each((i, el) => { + const $tgt = $(el); const $form = $tgt.closest('form'); - $tgt.select2({ - data: Object.keys(predefined), - }).on('select2:select', evt => { - const filterName = evt.params.data.id; - const preset = predefined[filterName]; + $tgt.select2({ + data: Object.keys(predefined), + }).on('select2:select', evt => { + const filterName = evt.params.data.id; + const preset = predefined[filterName]; for (const [name, value] of Object.entries(preset)) { $form.find(`.filter-${name}`).val(value); } - }); + }); - // Clear the preset when the user enters in a filter manually - $form.find('.form--filter').each((i, filter) => { - $(filter).on('change', _ => { - $tgt.val(null).trigger('change'); - }); - }); - }); + // Clear the preset when the user enters in a filter manually + $form.find('.form--filter').each((i, filter) => { + $(filter).on('change', _ => { + $tgt.val(null).trigger('change'); + }); + }); + }); }); \ No newline at end of file From 0bcfe8245658f9b66a526004e07f49ef7c70b6aa Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 18:35:37 +0800 Subject: [PATCH 013/968] Add preset filter for unanswered questions --- app/assets/javascripts/filters.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 7d3d320d6..4693ce42a 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -3,6 +3,10 @@ $(() => { 'Positive': { 'score-min': 0.5, 'score-max': 1 + }, + 'Unanswered': { + 'answers-min': 0, + 'answers-max': 0 } }; From 34928a58db55b2f50fdc4ba2b603f2a35fa6f4c9 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 18:53:22 +0800 Subject: [PATCH 014/968] Add reset button for filters --- app/assets/javascripts/filters.js | 7 +++++++ app/views/search/_filters.html.erb | 11 +++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 4693ce42a..a58a1adc2 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -32,4 +32,11 @@ $(() => { }); }); }); + + $('.filter-clear').on('click', evt => { + const $tgt = $(evt.target); + const $form = $tgt.closest('form'); + + $form.find('.form--filter').val(null).trigger('change'); + }); }); \ No newline at end of file diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index e05fd6333..70fc56b40 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -1,9 +1,12 @@
- <%= label_tag :predefined_filter, 'Predefined Filters', class: "form-element" %> - <%= select_tag :predefined_filter, options_for_select([]), - include_blank: true, class: "form-element js-filter-select", id: nil, - data: { placeholder: "" } %> +
+ <%= label_tag :predefined_filter, 'Predefined Filters', class: "form-element" %> + <%= select_tag :predefined_filter, options_for_select([]), + include_blank: true, class: "form-element js-filter-select", id: nil, + data: { placeholder: "" } %> +
+
From 8da46bd7fed1dc6ef28b4cdf83623d43f4b025d8 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 19:29:46 +0800 Subject: [PATCH 015/968] Enforce category permissions --- app/helpers/search_helper.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index d53ad0864..28f621ab4 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -131,7 +131,11 @@ def parse_qualifier_strings(qualifiers) end def qualifiers_to_sql(qualifiers, query) - qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength + trust_level = current_user&.trust_level || 0 + allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) + query = query.where(category_id: allowed_categories) + + qualifiers.each do |qualifier| case qualifier[:param] when :score query = query.where("score #{qualifier[:operator]} ?", qualifier[:value]) @@ -152,10 +156,7 @@ def qualifiers_to_sql(qualifiers, query) when :exclude_tag query = query.where.not(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) }) when :category - trust_level = current_user&.trust_level || 0 - allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) query = query.where("category_id #{qualifier[:operator]} ?", qualifier[:category_id]) - .where(category_id: allowed_categories) when :post_type query = query.where("post_type_id #{qualifier[:operator]} ?", qualifier[:post_type_id]) when :answers From f1d6947654ee2ff2e88e83abe922cf020a062b4a Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 19:41:22 +0800 Subject: [PATCH 016/968] Add search and filter by post status --- app/helpers/search_helper.rb | 19 ++++++++++++++++++- app/views/search/_filters.html.erb | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 28f621ab4..36834aa9b 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -29,6 +29,7 @@ def search_posts def filters_to_qualifiers valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, + status: /any|open|closed/, numeric: /^[\d.]+$/ } @@ -50,6 +51,10 @@ def filters_to_qualifiers filter_qualifiers.append({ param: :answers, operator: '<=', value: params[:filter_answers_max].to_i }) end + if params[:filter_status]&.match?(valid_value[:status]) + filter_qualifiers.append({ param: :status, value: params[:filter_status] }) + end + filter_qualifiers end @@ -68,6 +73,7 @@ def parse_search(raw_search) def parse_qualifier_strings(qualifiers) valid_value = { date: /^[<>=]{0,2}[\d.]+(?:s|m|h|d|w|mo|y)?$/, + status: /any|open|closed/, numeric: /^[<>=]{0,2}[\d.]+$/ } @@ -126,6 +132,10 @@ def parse_qualifier_strings(qualifiers) operator, val = numeric_value_sql value { param: :answers, operator: operator.presence || '=', value: val.to_i } + when 'status' + next unless value.match?(valid_value[:status]) + + { param: :status, value: value } end end end @@ -135,7 +145,7 @@ def qualifiers_to_sql(qualifiers, query) allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) query = query.where(category_id: allowed_categories) - qualifiers.each do |qualifier| + qualifiers.each do |qualifier| # rubocop:disable Metrics/BlockLength case qualifier[:param] when :score query = query.where("score #{qualifier[:operator]} ?", qualifier[:value]) @@ -163,6 +173,13 @@ def qualifiers_to_sql(qualifiers, query) post_types_with_answers = PostType.where(has_answers: true) query = query.where("answer_count #{qualifier[:operator]} ?", qualifier[:value]) .where(post_type_id: post_types_with_answers) + when :status + case qualifier[:value] + when 'open' + query = query.where(closed: false) + when 'closed' + query = query.where(closed: true) + end end end diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 70fc56b40..45a766b9a 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -32,4 +32,9 @@ min: 0, step: 1, class: 'form-element form--filter filter-answers-max' %>
+
+ <%= label_tag :filter_status, 'Status', class: "form-element" %> + <%= select_tag :filter_status, options_for_select(['any', 'open', 'closed'], selected: params[:filter_status] || 'any'), + min: 0, step: 1, class: 'form-element form--filter filter-status' %> +
From 3d3e23a7765a6c01bb80afcf83f83885d1e4ec4a Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 19:43:41 +0800 Subject: [PATCH 017/968] Update presets --- app/assets/javascripts/filters.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index a58a1adc2..dfdc7ed4c 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -2,11 +2,13 @@ $(() => { const predefined = { 'Positive': { 'score-min': 0.5, - 'score-max': 1 + 'score-max': 1, + 'status': 'open' }, 'Unanswered': { 'answers-min': 0, - 'answers-max': 0 + 'answers-max': 0, + 'status': 'open' } }; From 624454c8a7b953f9459de9e8154f7737df1b89f4 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 08:54:04 -0700 Subject: [PATCH 018/968] Only show filter widget on category feed page --- app/views/layouts/_sidebar.html.erb | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 715412ad1..eda03aef4 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -86,19 +86,21 @@ <% end %> -
-
- Filters -
-
- <%= form_tag request.original_url, method: :get do %> - <%= render 'search/filters' %> -
- <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> -
- <% end %> + <% if defined?(@category) && current_page?(category_path(@category)) %> +
+
+ Filters +
+
+ <%= form_tag request.original_url, method: :get do %> + <%= render 'search/filters' %> +
+ <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> +
+ <% end %> +
-
+ <% end %> <% unless @community.is_fake %> <% if user_signed_in? %>
From 02b821bd17b8353a87c6cd36d5acdd3eb08ff888 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 21:49:07 -0700 Subject: [PATCH 019/968] Add filters to database --- app/models/filter.rb | 3 +++ app/models/user.rb | 1 + db/migrate/20220914044523_create_filters.rb | 15 +++++++++++++++ test/fixtures/filters.yml | 17 +++++++++++++++++ test/models/filter_test.rb | 7 +++++++ 5 files changed, 43 insertions(+) create mode 100644 app/models/filter.rb create mode 100644 db/migrate/20220914044523_create_filters.rb create mode 100644 test/fixtures/filters.yml create mode 100644 test/models/filter_test.rb diff --git a/app/models/filter.rb b/app/models/filter.rb new file mode 100644 index 000000000..8d9df9098 --- /dev/null +++ b/app/models/filter.rb @@ -0,0 +1,3 @@ +class Filter < ApplicationRecord + belongs_to :user, required: true, class_name: 'User' +end diff --git a/app/models/user.rb b/app/models/user.rb index 84d79db01..628e3f817 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,6 +24,7 @@ class User < ApplicationRecord has_many :comments, dependent: :nullify has_many :comment_threads_deleted, class_name: 'CommentThread', foreign_key: :deleted_by_id, dependent: :nullify has_many :comment_threads_locked, class_name: 'CommentThread', foreign_key: :locked_by_id, dependent: :nullify + has_many :filters, dependent: :destroy belongs_to :deleted_by, required: false, class_name: 'User' validates :username, presence: true, length: { minimum: 3, maximum: 50 } diff --git a/db/migrate/20220914044523_create_filters.rb b/db/migrate/20220914044523_create_filters.rb new file mode 100644 index 000000000..3659b63b9 --- /dev/null +++ b/db/migrate/20220914044523_create_filters.rb @@ -0,0 +1,15 @@ +class CreateFilters < ActiveRecord::Migration[7.0] + def change + create_table :filters do |t| + t.references :user, foreign_key: true + t.string :name + t.float :min_score + t.float :max_score + t.integer :min_answers + t.integer :max_answers + t.string :status + + t.timestamps + end + end +end diff --git a/test/fixtures/filters.yml b/test/fixtures/filters.yml new file mode 100644 index 000000000..8d33b4a27 --- /dev/null +++ b/test/fixtures/filters.yml @@ -0,0 +1,17 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + min_score: 1.5 + max_score: 1.5 + min_answers: 1 + max_answers: 1 + status: MyString + +two: + name: MyString + min_score: 1.5 + max_score: 1.5 + min_answers: 1 + max_answers: 1 + status: MyString diff --git a/test/models/filter_test.rb b/test/models/filter_test.rb new file mode 100644 index 000000000..f9dee97f8 --- /dev/null +++ b/test/models/filter_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class FilterTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From 959f4426316e5ae85be1f33e89105607111bbe74 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 22:29:46 -0700 Subject: [PATCH 020/968] Add routes to get/set filters --- app/controllers/users_controller.rb | 21 +++++++++++++++++++++ config/routes.rb | 2 ++ db/schema.rb | 19 ++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 11e95df0d..368db2e32 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -64,6 +64,27 @@ def preferences end end + def filters + respond_to do |format| + format.json do + render json: current_user.filters.select(:name, :min_score, :max_score, :min_answers, :max_answers, :status) + end + end + end + + def set_filter + if params[:name] + filter = Filter.find_or_create_by(user: current_user, name: params[:name]) + filter.update(min_score: params[:min_score], max_score: params[:max_score], min_answers: params[:min_answers], + max_answers: params[:max_answers], status: params[:status]) + + render json: { status: 'success', success: true } + else + render json: { status: 'failed', success: false, errors: ['Filter name is required'] }, + status: 400 + end + end + def set_preference if !params[:name].nil? && !params[:value].nil? global_key = "prefs.#{current_user.id}" diff --git a/config/routes.rb b/config/routes.rb index 694164c6d..ac482f59a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -170,6 +170,8 @@ get '/me', to: 'users#me', as: :users_me get '/me/preferences', to: 'users#preferences', as: :user_preferences post '/me/preferences', to: 'users#set_preference', as: :set_user_preference + get '/me/filters', to: 'users#filters', as: :user_filters + post '/me/filters', to: 'users#set_filter', as: :set_user_filter get '/me/notifications', to: 'notifications#index', as: :notifications get '/edit/profile', to: 'users#edit_profile', as: :edit_user_profile patch '/edit/profile', to: 'users#update_profile', as: :update_user_profile diff --git a/db/schema.rb b/db/schema.rb index ba718100c..19a10f8f2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_09_03_174045) do +ActiveRecord::Schema[7.0].define(version: 2022_09_14_044523) do create_table "abilities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "community_id" t.string "name" @@ -234,6 +234,19 @@ t.index ["user_id"], name: "index_error_logs_on_user_id" end + create_table "filters", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "user_id" + t.string "name" + t.float "min_score" + t.float "max_score" + t.integer "min_answers" + t.integer "max_answers" + t.string "status" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_filters_on_user_id" + end + create_table "flags", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.text "reason" t.datetime "created_at", precision: nil, null: false @@ -614,7 +627,9 @@ t.bigint "user_id" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.bigint "post_id" t.index ["comment_thread_id"], name: "index_thread_followers_on_comment_thread_id" + t.index ["post_id"], name: "index_thread_followers_on_post_id" t.index ["user_id"], name: "index_thread_followers_on_user_id" end @@ -733,6 +748,7 @@ add_foreign_key "community_users", "users", column: "deleted_by_id" add_foreign_key "error_logs", "communities" add_foreign_key "error_logs", "users" + add_foreign_key "filters", "users" add_foreign_key "flags", "communities" add_foreign_key "flags", "users", column: "escalated_by_id" add_foreign_key "micro_auth_apps", "users" @@ -761,6 +777,7 @@ add_foreign_key "suggested_edits", "users", column: "decided_by_id" add_foreign_key "tags", "communities" add_foreign_key "tags", "tags", column: "parent_id" + add_foreign_key "thread_followers", "posts" add_foreign_key "user_abilities", "abilities" add_foreign_key "user_abilities", "community_users" add_foreign_key "users", "users", column: "deleted_by_id" From b26d191ae4c7bd67a86aa77c9e5dbaf11b570104 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 23:33:49 -0700 Subject: [PATCH 021/968] Convert response to a nicer client format --- app/controllers/users_controller.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 368db2e32..033fa6aa9 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -67,7 +67,15 @@ def preferences def filters respond_to do |format| format.json do - render json: current_user.filters.select(:name, :min_score, :max_score, :min_answers, :max_answers, :status) + render json: current_user.filters.to_h { |filter| + [filter.name, { + 'score-min' => filter.min_score, + 'score-max' => filter.max_score, + 'answer-min' => filter.min_answers, + 'answer-max' => filter.max_answers, + 'status' => filter.status + }] + } end end end From e64887271363ebb7bba76684c8b875f42acbd3f9 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 23:37:34 -0700 Subject: [PATCH 022/968] Fetch user filters from server --- app/assets/javascripts/filters.js | 48 +++++++++++----------------- app/assets/javascripts/qpixel_api.js | 21 ++++++++++++ 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index dfdc7ed4c..80c40abf7 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -1,40 +1,28 @@ $(() => { - const predefined = { - 'Positive': { - 'score-min': 0.5, - 'score-max': 1, - 'status': 'open' - }, - 'Unanswered': { - 'answers-min': 0, - 'answers-max': 0, - 'status': 'open' - } - }; + QPixel.filters().then(filters => { + $('.js-filter-select').each((i, el) => { + const $tgt = $(el); + const $form = $tgt.closest('form'); - $('.js-filter-select').each((i, el) => { - const $tgt = $(el); - const $form = $tgt.closest('form'); - - $tgt.select2({ - data: Object.keys(predefined), - }).on('select2:select', evt => { - const filterName = evt.params.data.id; - const preset = predefined[filterName]; + $tgt.select2({ + data: Object.keys(filters), + }).on('select2:select', evt => { + const filterName = evt.params.data.id; + const preset = filters[filterName]; - for (const [name, value] of Object.entries(preset)) { - $form.find(`.filter-${name}`).val(value); - } - }); + for (const [name, value] of Object.entries(preset)) { + $form.find(`.filter-${name}`).val(value); + } + }); - // Clear the preset when the user enters in a filter manually - $form.find('.form--filter').each((i, filter) => { - $(filter).on('change', _ => { - $tgt.val(null).trigger('change'); + // Clear the preset when the user enters in a filter manually + $form.find('.form--filter').each((i, filter) => { + $(filter).on('change', _ => { + $tgt.val(null).trigger('change'); + }); }); }); }); - $('.filter-clear').on('click', evt => { const $tgt = $(evt.target); const $form = $tgt.closest('form'); diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index df4236002..e940ab5e6 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -263,6 +263,27 @@ window.QPixel = { } }, + filters: async () => { + if (this._filters == null && localStorage['qpixel.user_filters']) { + this._filters = JSON.parse(localStorage['qpixel.user_filters']); + } + else if (this._filters == null) { + // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't + // loaded them for yet. Load via AJAX. + const resp = await fetch('/users/me/filters', { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + const data = await resp.json(); + localStorage['qpixel.user_filters'] = JSON.stringify(data); + this._filters = data; + } + + return this._filters; + }, + /** * Get the word in a string that the given position is in, and the position within that word. * @param splat an array, containing the string already split by however you define a "word" From 6ea26f511f2d5203d9ede127782e3e0247d4610b Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 13 Sep 2022 23:48:46 -0700 Subject: [PATCH 023/968] Seed filters --- db/seeds/filters.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 db/seeds/filters.yml diff --git a/db/seeds/filters.yml b/db/seeds/filters.yml new file mode 100644 index 000000000..4e97a8f40 --- /dev/null +++ b/db/seeds/filters.yml @@ -0,0 +1,11 @@ +- name: Positive + user_id: -1 + min_score: 0.5 + max_score: 1 + status: 'open' + +- name: Unanswered + user_id: -1 + min_answers: 0 + max_answers: 0 + status: 'open' \ No newline at end of file From 4bf08727a4f31b47a9ca5d97bc2144ed9cd0d29a Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 00:04:44 -0700 Subject: [PATCH 024/968] Add system filters to response --- app/controllers/users_controller.rb | 30 +++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 033fa6aa9..8a35317fc 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -65,17 +65,31 @@ def preferences end def filters + system_filters = Rails.cache.fetch 'system_filters' do + User.find(-1).filters.to_h do |filter| + [filter.name, { + 'score-min' => filter.min_score, + 'score-max' => filter.max_score, + 'answers-min' => filter.min_answers, + 'answers-max' => filter.max_answers, + 'status' => filter.status, + 'system' => true + }] + end + end + respond_to do |format| format.json do render json: current_user.filters.to_h { |filter| - [filter.name, { - 'score-min' => filter.min_score, - 'score-max' => filter.max_score, - 'answer-min' => filter.min_answers, - 'answer-max' => filter.max_answers, - 'status' => filter.status - }] - } + [filter.name, { + 'score-min' => filter.min_score, + 'score-max' => filter.max_score, + 'answers-min' => filter.min_answers, + 'answers-max' => filter.max_answers, + 'status' => filter.status, + 'system' => false + }] + }.merge(system_filters) end end end From 91be2e22a322ce5f66ba9a07363cfddad73f4fe3 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 00:31:44 -0700 Subject: [PATCH 025/968] Refactor json conversion --- app/controllers/users_controller.rb | 23 +++-------------------- app/models/filter.rb | 12 ++++++++++++ 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 8a35317fc..5b3bb0e7e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -66,30 +66,13 @@ def preferences def filters system_filters = Rails.cache.fetch 'system_filters' do - User.find(-1).filters.to_h do |filter| - [filter.name, { - 'score-min' => filter.min_score, - 'score-max' => filter.max_score, - 'answers-min' => filter.min_answers, - 'answers-max' => filter.max_answers, - 'status' => filter.status, - 'system' => true - }] - end + User.find(-1).filters.to_h { |filter| [filter.name, filter.json] } end respond_to do |format| format.json do - render json: current_user.filters.to_h { |filter| - [filter.name, { - 'score-min' => filter.min_score, - 'score-max' => filter.max_score, - 'answers-min' => filter.min_answers, - 'answers-max' => filter.max_answers, - 'status' => filter.status, - 'system' => false - }] - }.merge(system_filters) + render json: current_user.filters.to_h { |filter| [filter.name, filter.json] } + .merge(system_filters) end end end diff --git a/app/models/filter.rb b/app/models/filter.rb index 8d9df9098..87e757bdc 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -1,3 +1,15 @@ class Filter < ApplicationRecord belongs_to :user, required: true, class_name: 'User' + + # Helper method to convert it to the form expected by the client + def json + { + 'score-min' => min_score, + 'score-max' => max_score, + 'answers-min' => min_answers, + 'answers-max' => max_answers, + 'status' => status, + 'system' => user_id == -1 + } + end end From 9f88a0f71a32d5422667ca39193e83adce847184 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 00:36:26 -0700 Subject: [PATCH 026/968] Give unsigned users the system filters --- app/controllers/users_controller.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5b3bb0e7e..962379677 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -71,8 +71,12 @@ def filters respond_to do |format| format.json do - render json: current_user.filters.to_h { |filter| [filter.name, filter.json] } - .merge(system_filters) + if current_user + render json: current_user.filters.to_h { |filter| [filter.name, filter.json] } + .merge(system_filters) + else + render json: system_filters + end end end end From 6a5d75a22d006d7573c1cc6a04f9e34f543a446c Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 10:00:00 -0700 Subject: [PATCH 027/968] Update app/controllers/users_controller.rb Co-authored-by: ArtOfCode --- app/controllers/users_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 962379677..b3a56e679 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -71,7 +71,7 @@ def filters respond_to do |format| format.json do - if current_user + if user_signed_in? render json: current_user.filters.to_h { |filter| [filter.name, filter.json] } .merge(system_filters) else From a4ff51d64034bda43a0b07ba8280d8711874706e Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 10:05:13 -0700 Subject: [PATCH 028/968] Remove unnecessary code --- app/models/filter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/filter.rb b/app/models/filter.rb index 87e757bdc..db9f344c8 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -1,5 +1,5 @@ class Filter < ApplicationRecord - belongs_to :user, required: true, class_name: 'User' + belongs_to :user # Helper method to convert it to the form expected by the client def json From b5faa6f8acc7c9967d7cad533aced2d33e5230fa Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 10:31:50 -0700 Subject: [PATCH 029/968] Add filter-set to client QPixel API --- app/assets/javascripts/qpixel_api.js | 22 ++++++++++++++++++++++ app/controllers/users_controller.rb | 20 ++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index e940ab5e6..1d4e062fb 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -284,6 +284,28 @@ window.QPixel = { return this._filters; }, + setFilter: async (name, filter) => { + const resp = await fetch('/users/me/filters', { + method: 'POST', + credentials: 'include', + headers: { + 'X-CSRF-Token': QPixel.csrfToken(), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(Object.assign(filter, { name })) + }); + const data = await resp.json(); + if (data.status !== 'success') { + console.error(`Filter persist failed (${name})`); + console.error(resp); + } + else { + this._filters = data.filters; + localStorage['qpixel.user_filters'] = JSON.stringify(this._filters); + } + }, + /** * Get the word in a string that the given position is in, and the position within that word. * @param splat an array, containing the string already split by however you define a "word" diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index b3a56e679..99692f486 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -64,19 +64,23 @@ def preferences end end - def filters + def filters_json system_filters = Rails.cache.fetch 'system_filters' do User.find(-1).filters.to_h { |filter| [filter.name, filter.json] } end + if user_signed_in? + current_user.filters.to_h { |filter| [filter.name, filter.json] } + .merge(system_filters) + else + system_filters + end + end + + def filters respond_to do |format| format.json do - if user_signed_in? - render json: current_user.filters.to_h { |filter| [filter.name, filter.json] } - .merge(system_filters) - else - render json: system_filters - end + render json: filters_json end end end @@ -87,7 +91,7 @@ def set_filter filter.update(min_score: params[:min_score], max_score: params[:max_score], min_answers: params[:min_answers], max_answers: params[:max_answers], status: params[:status]) - render json: { status: 'success', success: true } + render json: { status: 'success', success: true, filters: filters_json } else render json: { status: 'failed', success: false, errors: ['Filter name is required'] }, status: 400 From b62400aa0725e803602d823f4848af6147d4d024 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 10:49:24 -0700 Subject: [PATCH 030/968] Set up layouts for saving filters --- app/views/layouts/_sidebar.html.erb | 3 --- app/views/search/_filters.html.erb | 12 ++++++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index eda03aef4..8c0031860 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -94,9 +94,6 @@
<%= form_tag request.original_url, method: :get do %> <%= render 'search/filters' %> -
- <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> -
<% end %>
diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 45a766b9a..703fbf3c0 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -33,8 +33,12 @@
- <%= label_tag :filter_status, 'Status', class: "form-element" %> - <%= select_tag :filter_status, options_for_select(['any', 'open', 'closed'], selected: params[:filter_status] || 'any'), - min: 0, step: 1, class: 'form-element form--filter filter-status' %> -
+ <%= label_tag :filter_status, 'Status', class: "form-element" %> + <%= select_tag :filter_status, options_for_select(['any', 'open', 'closed'], selected: params[:filter_status] || 'any'), + min: 0, step: 1, class: 'form-element form--filter filter-status' %> + +
+ <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> + +
From 03cfd26470292503a87f23d39558b9ae330e3f46 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 11:54:08 -0700 Subject: [PATCH 031/968] Unify client and server names for filter params --- app/assets/javascripts/filters.js | 2 +- app/helpers/search_helper.rb | 20 ++++++++++---------- app/models/filter.rb | 12 ++++++------ app/views/search/_filters.html.erb | 30 +++++++++++++++--------------- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 80c40abf7..c3320a1ba 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -11,7 +11,7 @@ $(() => { const preset = filters[filterName]; for (const [name, value] of Object.entries(preset)) { - $form.find(`.filter-${name}`).val(value); + $form.find(`.form--filter[name=${name}]`).val(value); } }); diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 36834aa9b..bc28f8001 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -35,24 +35,24 @@ def filters_to_qualifiers filter_qualifiers = [] - if params[:filter_score_min]&.match?(valid_value[:numeric]) - filter_qualifiers.append({ param: :score, operator: '>=', value: params[:filter_score_min].to_f }) + if params[:min_score]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :score, operator: '>=', value: params[:min_score].to_f }) end - if params[:filter_score_max]&.match?(valid_value[:numeric]) - filter_qualifiers.append({ param: :score, operator: '<=', value: params[:filter_score_max].to_f }) + if params[:max_score]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :score, operator: '<=', value: params[:max_score].to_f }) end - if params[:filter_answers_min]&.match?(valid_value[:numeric]) - filter_qualifiers.append({ param: :answers, operator: '>=', value: params[:filter_answers_min].to_i }) + if params[:min_answers]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :answers, operator: '>=', value: params[:min_answers].to_i }) end - if params[:filter_answers_max]&.match?(valid_value[:numeric]) - filter_qualifiers.append({ param: :answers, operator: '<=', value: params[:filter_answers_max].to_i }) + if params[:max_answers]&.match?(valid_value[:numeric]) + filter_qualifiers.append({ param: :answers, operator: '<=', value: params[:max_answers].to_i }) end - if params[:filter_status]&.match?(valid_value[:status]) - filter_qualifiers.append({ param: :status, value: params[:filter_status] }) + if params[:status]&.match?(valid_value[:status]) + filter_qualifiers.append({ param: :status, value: params[:status] }) end filter_qualifiers diff --git a/app/models/filter.rb b/app/models/filter.rb index db9f344c8..9240c80e5 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -4,12 +4,12 @@ class Filter < ApplicationRecord # Helper method to convert it to the form expected by the client def json { - 'score-min' => min_score, - 'score-max' => max_score, - 'answers-min' => min_answers, - 'answers-max' => max_answers, - 'status' => status, - 'system' => user_id == -1 + min_score: min_score, + max_score: max_score, + min_answers: min_answers, + max_answers: max_answers, + status: status, + system: user_id == -1 } end end diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 703fbf3c0..47af8695c 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -10,32 +10,32 @@
- <%= label_tag :filter_score_min, 'Min Score', class: "form-element" %> - <%= number_field_tag :filter_score_min, params[:filter_score_min], - min: 0, max: 1, step: 0.01, class: 'form-element form--filter filter-score-min' %> + <%= label_tag :min_score, 'Min Score', class: "form-element" %> + <%= number_field_tag :min_score, params[:min_score], + min: 0, max: 1, step: 0.01, class: 'form-element form--filter' %>
- <%= label_tag :filter_score_max, 'Max Score', class: "form-element" %> - <%= number_field_tag :filter_score_max, params[:filter_score_max], - min: 0, max: 1, step: 0.01, class: 'form-element form--filter filter-score-max' %> + <%= label_tag :max_score, 'Max Score', class: "form-element" %> + <%= number_field_tag :max_score, params[:max_score], + min: 0, max: 1, step: 0.01, class: 'form-element form--filter' %>
- <%= label_tag :filter_answers_min, 'Min Answers', class: "form-element" %> - <%= number_field_tag :filter_answers_min, params[:filter_answers_min], - min: 0, step: 1, class: 'form-element form--filter filter-answers-min' %> + <%= label_tag :min_answers, 'Min Answers', class: "form-element" %> + <%= number_field_tag :min_answers, params[:min_answers], + min: 0, step: 1, class: 'form-element form--filter' %>
- <%= label_tag :filter_answers_max, 'Max Answers', class: "form-element" %> - <%= number_field_tag :filter_answers_max, params[:filter_answers_max], - min: 0, step: 1, class: 'form-element form--filter filter-answers-max' %> + <%= label_tag :max_answers, 'Max Answers', class: "form-element" %> + <%= number_field_tag :max_answers, params[:max_answers], + min: 0, step: 1, class: 'form-element form--filter' %>
- <%= label_tag :filter_status, 'Status', class: "form-element" %> - <%= select_tag :filter_status, options_for_select(['any', 'open', 'closed'], selected: params[:filter_status] || 'any'), - min: 0, step: 1, class: 'form-element form--filter filter-status' %> + <%= label_tag :status, 'Status', class: "form-element" %> + <%= select_tag :status, options_for_select(['any', 'open', 'closed'], selected: params[:status] || 'any'), + min: 0, step: 1, class: 'form-element form--filter' %>
<%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> From c9adefe11e14a1546dc0ab9e771da78be29ae022 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 12:36:32 -0700 Subject: [PATCH 032/968] Set user and name to nonnullable --- .../20220914193044_diallow_filter_user_or_name_null.rb | 6 ++++++ db/schema.rb | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20220914193044_diallow_filter_user_or_name_null.rb diff --git a/db/migrate/20220914193044_diallow_filter_user_or_name_null.rb b/db/migrate/20220914193044_diallow_filter_user_or_name_null.rb new file mode 100644 index 000000000..888324316 --- /dev/null +++ b/db/migrate/20220914193044_diallow_filter_user_or_name_null.rb @@ -0,0 +1,6 @@ +class DiallowFilterUserOrNameNull < ActiveRecord::Migration[7.0] + def change + change_column_null :filters, :user_id, false + change_column_null :filters, :name, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 19a10f8f2..03093d545 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_09_14_044523) do +ActiveRecord::Schema[7.0].define(version: 2022_09_14_193044) do create_table "abilities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "community_id" t.string "name" @@ -235,8 +235,8 @@ end create_table "filters", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.bigint "user_id" - t.string "name" + t.bigint "user_id", null: false + t.string "name", null: false t.float "min_score" t.float "max_score" t.integer "min_answers" From 043f1a2c1e6ca94ff4aecb135d62c90dfedf697e Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 13:11:55 -0700 Subject: [PATCH 033/968] Make user able to add new filters --- app/assets/javascripts/filters.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index c3320a1ba..3982b6edd 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -6,10 +6,14 @@ $(() => { $tgt.select2({ data: Object.keys(filters), + tags: true, }).on('select2:select', evt => { const filterName = evt.params.data.id; const preset = filters[filterName]; + // Name is not one of the presets, i.e user is creating a new preset + if (!preset) { return; } + for (const [name, value] of Object.entries(preset)) { $form.find(`.form--filter[name=${name}]`).val(value); } @@ -21,6 +25,15 @@ $(() => { $tgt.val(null).trigger('change'); }); }); + + $('.filter-save').on('click', evt => { + const filter = {}; + + for (const el of $('.form--filter')) { + filter[el.name] = el.value; + } + QPixel.setFilter($tgt.val(), filter) + }); }); }); $('.filter-clear').on('click', evt => { From a7b0d429eaaa17ef2a179480405fe1268da99d3f Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 13:12:48 -0700 Subject: [PATCH 034/968] Indicate system presets --- app/assets/javascripts/filters.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 3982b6edd..15c72a8aa 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -7,6 +7,15 @@ $(() => { $tgt.select2({ data: Object.keys(filters), tags: true, + + templateResult: option => { + const filter = filters[option.id]; + const name = `${option.text}`; + const systemIndicator = filter?.system + ? ' (System)' + : '' + return $(name + systemIndicator); + } }).on('select2:select', evt => { const filterName = evt.params.data.id; const preset = filters[filterName]; From a740af51f31a6fb3087753485a0bcc8cab605f57 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 13:27:38 -0700 Subject: [PATCH 035/968] Format both result and options of select --- app/assets/javascripts/filters.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 15c72a8aa..98f99566b 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -1,5 +1,19 @@ $(() => { QPixel.filters().then(filters => { + function template(option) { + if (option.id == '') { return 'None'; } + + const filter = filters[option.id]; + const name = `${option.text}`; + const systemIndicator = filter?.system + ? ' (System)' + : ''; + const newIndicator = !filter + ? ' (New)' + : ''; + return $(name + systemIndicator + newIndicator); + } + $('.js-filter-select').each((i, el) => { const $tgt = $(el); const $form = $tgt.closest('form'); @@ -8,14 +22,8 @@ $(() => { data: Object.keys(filters), tags: true, - templateResult: option => { - const filter = filters[option.id]; - const name = `${option.text}`; - const systemIndicator = filter?.system - ? ' (System)' - : '' - return $(name + systemIndicator); - } + templateResult: template, + templateSelection: template }).on('select2:select', evt => { const filterName = evt.params.data.id; const preset = filters[filterName]; From 60e71927da1a8f3705a84564515d23447fb19a93 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 13:57:11 -0700 Subject: [PATCH 036/968] Save button improvements --- app/assets/javascripts/filters.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 98f99566b..57cbd2942 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -12,16 +12,17 @@ $(() => { ? ' (New)' : ''; return $(name + systemIndicator + newIndicator); - } + } $('.js-filter-select').each((i, el) => { const $tgt = $(el); const $form = $tgt.closest('form'); + const $saveButton = $('.filter-save'); $tgt.select2({ data: Object.keys(filters), tags: true, - + templateResult: template, templateSelection: template }).on('select2:select', evt => { @@ -31,6 +32,8 @@ $(() => { // Name is not one of the presets, i.e user is creating a new preset if (!preset) { return; } + $saveButton.prop('disabled', true); + for (const [name, value] of Object.entries(preset)) { $form.find(`.form--filter[name=${name}]`).val(value); } @@ -39,17 +42,22 @@ $(() => { // Clear the preset when the user enters in a filter manually $form.find('.form--filter').each((i, filter) => { $(filter).on('change', _ => { - $tgt.val(null).trigger('change'); + $saveButton.prop('disabled', false); }); }); - $('.filter-save').on('click', evt => { + $saveButton.on('click', async evt => { + if (!$form[0].reportValidity()) { + return; + } + const filter = {}; for (const el of $('.form--filter')) { filter[el.name] = el.value; } - QPixel.setFilter($tgt.val(), filter) + await QPixel.setFilter($tgt.val(), filter); + $saveButton.prop('disabled', true); }); }); }); From fc2de9901d310853d64c9cabe32cdc3021a20f3b Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 14:07:21 -0700 Subject: [PATCH 037/968] Minor code refactor --- app/assets/javascripts/filters.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 57cbd2942..4ff635d78 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -39,7 +39,7 @@ $(() => { } }); - // Clear the preset when the user enters in a filter manually + // Enable saving when the filter is changed $form.find('.form--filter').each((i, filter) => { $(filter).on('change', _ => { $saveButton.prop('disabled', false); @@ -59,12 +59,10 @@ $(() => { await QPixel.setFilter($tgt.val(), filter); $saveButton.prop('disabled', true); }); - }); - }); - $('.filter-clear').on('click', evt => { - const $tgt = $(evt.target); - const $form = $tgt.closest('form'); - $form.find('.form--filter').val(null).trigger('change'); + $form.find('.filter-clear').on('click', _ => { + $form.find('.form--filter').val(null).trigger('change'); + }); + }); }); }); \ No newline at end of file From bcad1819c54dd818495fef2a6ee5835322a4732b Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 14:08:18 -0700 Subject: [PATCH 038/968] Clear filter name too --- app/assets/javascripts/filters.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 4ff635d78..62a549213 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -61,6 +61,7 @@ $(() => { }); $form.find('.filter-clear').on('click', _ => { + $tgt.val(null).trigger('change'); $form.find('.form--filter').val(null).trigger('change'); }); }); From 6d22a66f99318541e022f09547cd6108e484d4ec Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 14:58:13 -0700 Subject: [PATCH 039/968] Reinitialize select options when changed --- app/assets/javascripts/filters.js | 93 ++++++++++++++++--------------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 62a549213..a5ae3d38e 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -1,69 +1,74 @@ $(() => { - QPixel.filters().then(filters => { - function template(option) { - if (option.id == '') { return 'None'; } + $('.js-filter-select').each((_, el) => { + const $select = $(el); + const $form = $select.closest('form'); + const $saveButton = $form.find('.filter-save'); - const filter = filters[option.id]; - const name = `${option.text}`; - const systemIndicator = filter?.system - ? ' (System)' - : ''; - const newIndicator = !filter - ? ' (New)' - : ''; - return $(name + systemIndicator + newIndicator); - } - - $('.js-filter-select').each((i, el) => { - const $tgt = $(el); - const $form = $tgt.closest('form'); - const $saveButton = $('.filter-save'); - - $tgt.select2({ + async function initializeSelect() { + const filters = await QPixel.filters(); + + function template(option) { + if (option.id == '') { return 'None'; } + + const filter = filters[option.id]; + const name = `${option.text}`; + const systemIndicator = filter?.system + ? ' (System)' + : ''; + const newIndicator = !filter + ? ' (New)' + : ''; + return $(name + systemIndicator + newIndicator); + } + + $select.select2({ data: Object.keys(filters), tags: true, - + templateResult: template, templateSelection: template }).on('select2:select', evt => { const filterName = evt.params.data.id; const preset = filters[filterName]; - + // Name is not one of the presets, i.e user is creating a new preset if (!preset) { return; } - + $saveButton.prop('disabled', true); - + for (const [name, value] of Object.entries(preset)) { $form.find(`.form--filter[name=${name}]`).val(value); } }); + } + + initializeSelect(); - // Enable saving when the filter is changed - $form.find('.form--filter').each((i, filter) => { - $(filter).on('change', _ => { - $saveButton.prop('disabled', false); - }); + // Enable saving when the filter is changed + $form.find('.form--filter').each((i, filter) => { + $(filter).on('change', _ => { + $saveButton.prop('disabled', false); }); + }); - $saveButton.on('click', async evt => { - if (!$form[0].reportValidity()) { - return; - } + $saveButton.on('click', async evt => { + if (!$form[0].reportValidity()) { return; } - const filter = {}; + const filter = {}; - for (const el of $('.form--filter')) { - filter[el.name] = el.value; - } - await QPixel.setFilter($tgt.val(), filter); - $saveButton.prop('disabled', true); - }); + for (const el of $('.form--filter')) { + filter[el.name] = el.value; + } - $form.find('.filter-clear').on('click', _ => { - $tgt.val(null).trigger('change'); - $form.find('.form--filter').val(null).trigger('change'); - }); + await QPixel.setFilter($select.val(), filter); + // Reinitialize to get new options + await initializeSelect(); + $saveButton.prop('disabled', true); + }); + + $form.find('.filter-clear').on('click', _ => { + $select.val(null).trigger('change'); + $form.find('.form--filter').val(null).trigger('change'); }); }); }); \ No newline at end of file From cfd4ad56546d56f623a95ab9cecaa0557cf29ad1 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 15:46:17 -0700 Subject: [PATCH 040/968] Set up user page to manage filters --- app/controllers/users_controller.rb | 1 + app/views/search/_filters.html.erb | 10 +++++++++- app/views/users/_tabs.html.erb | 3 +++ app/views/users/filters.html.erb | 11 +++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 app/views/users/filters.html.erb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 99692f486..7206d866e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -79,6 +79,7 @@ def filters_json def filters respond_to do |format| + format.html format.json do render json: filters_json end diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 47af8695c..6e53c3a55 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -1,3 +1,6 @@ +<% allow_delete ||= false %> +<% allow_apply = true if allow_apply.nil? %> +
@@ -38,7 +41,12 @@ min: 0, step: 1, class: 'form-element form--filter' %>
- <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> + <% if allow_apply %> + <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> + <% end %> + <% if allow_delete %> + + <% end %>
diff --git a/app/views/users/_tabs.html.erb b/app/views/users/_tabs.html.erb index 0c4713fef..42d486706 100644 --- a/app/views/users/_tabs.html.erb +++ b/app/views/users/_tabs.html.erb @@ -18,5 +18,8 @@ <%= link_to user_preferences_path, class: "tabs--item #{current_page?(user_preferences_path) ? 'is-active' : ''}" do %> Preferences <% end %> + <%= link_to user_filters_path, class: "tabs--item #{current_page?(user_filters_path) ? 'is-active' : ''}" do %> + Filters + <% end %> <% end %>
\ No newline at end of file diff --git a/app/views/users/filters.html.erb b/app/views/users/filters.html.erb new file mode 100644 index 000000000..95a83c71f --- /dev/null +++ b/app/views/users/filters.html.erb @@ -0,0 +1,11 @@ +<%= render 'tabs', user: current_user %> + +

Filters

+

+ Manage your filters here +

+ +<%# Just a nominal form so that stuff like validation works %> +
+ <%= render 'search/filters', allow_apply: false, allow_delete: true %> + \ No newline at end of file From 964cfe722ca25f6e35a14f127d4aa3a9b81798c6 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 16:41:25 -0700 Subject: [PATCH 041/968] Implement filter deletion --- app/assets/javascripts/filters.js | 24 +++++++++++++++++------- app/assets/javascripts/qpixel_api.js | 22 ++++++++++++++++++++++ app/controllers/users_controller.rb | 26 ++++++++++++++++++++++++++ config/routes.rb | 1 + 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index a5ae3d38e..cc35b162c 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -3,13 +3,14 @@ $(() => { const $select = $(el); const $form = $select.closest('form'); const $saveButton = $form.find('.filter-save'); + const $deleteButton = $form.find('.filter-delete'); async function initializeSelect() { const filters = await QPixel.filters(); - + function template(option) { if (option.id == '') { return 'None'; } - + const filter = filters[option.id]; const name = `${option.text}`; const systemIndicator = filter?.system @@ -20,22 +21,22 @@ $(() => { : ''; return $(name + systemIndicator + newIndicator); } - + $select.select2({ data: Object.keys(filters), tags: true, - + templateResult: template, templateSelection: template }).on('select2:select', evt => { const filterName = evt.params.data.id; const preset = filters[filterName]; - + // Name is not one of the presets, i.e user is creating a new preset if (!preset) { return; } - + $saveButton.prop('disabled', true); - + for (const [name, value] of Object.entries(preset)) { $form.find(`.form--filter[name=${name}]`).val(value); } @@ -66,6 +67,15 @@ $(() => { $saveButton.prop('disabled', true); }); + $deleteButton?.on('click', async evt => { + if (confirm(`Are you sure you want to delete ${$select.val()}?`)) { + await QPixel.deleteFilter($select.val()); + // Reinitialize to get new options + await initializeSelect(); + $saveButton.prop('disabled', true); + } + }); + $form.find('.filter-clear').on('click', _ => { $select.val(null).trigger('change'); $form.find('.form--filter').val(null).trigger('change'); diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 1d4e062fb..e80cc73a8 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -306,6 +306,28 @@ window.QPixel = { } }, + deleteFilter: async (name, system = false) => { + const resp = await fetch('/users/me/filters', { + method: 'DELETE', + credentials: 'include', + headers: { + 'X-CSRF-Token': QPixel.csrfToken(), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name, system }) + }); + const data = await resp.json(); + if (data.status !== 'success') { + console.error(`Filter deletion failed (${name})`); + console.error(resp); + } + else { + this._filters = data.filters; + localStorage['qpixel.user_filters'] = JSON.stringify(this._filters); + } + }, + /** * Get the word in a string that the given position is in, and the position within that word. * @param splat an array, containing the string already split by however you define a "word" diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7206d866e..f3cd772d9 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -99,6 +99,32 @@ def set_filter end end + def delete_filter + unless params[:name] + return render json: { status: 'failed', success: false, errors: ['Filter name is required'] }, + status: 400 + end + + as_user = current_user + + if params[:system] == true + if current_user&.is_global_admin + as_user = User.find(-1) + else + return render json: { status: 'failed', success: false, errors: ['You do not have permission to delete'] }, + status: 400 + end + end + + filter = Filter.find_by(user: as_user, name: params[:name]) + if filter.destroy + render json: { status: 'success', success: true, filters: filters_json } + else + render json: { status: 'failed', success: false, errors: ['Failed to delete'] }, + status: 400 + end + end + def set_preference if !params[:name].nil? && !params[:value].nil? global_key = "prefs.#{current_user.id}" diff --git a/config/routes.rb b/config/routes.rb index ac482f59a..809a72323 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -172,6 +172,7 @@ post '/me/preferences', to: 'users#set_preference', as: :set_user_preference get '/me/filters', to: 'users#filters', as: :user_filters post '/me/filters', to: 'users#set_filter', as: :set_user_filter + delete '/me/filters', to: 'users#delete_filter', as: :delete_user_filter get '/me/notifications', to: 'notifications#index', as: :notifications get '/edit/profile', to: 'users#edit_profile', as: :edit_user_profile patch '/edit/profile', to: 'users#update_profile', as: :update_user_profile From 11d739dc8fbf91d05bbb602fdc7c13c745922858 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 17:04:06 -0700 Subject: [PATCH 042/968] Fix bug where deleted filters remained in select --- app/assets/javascripts/filters.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index cc35b162c..2fa6b8771 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -22,6 +22,8 @@ $(() => { return $(name + systemIndicator + newIndicator); } + // Clear out any old options + $select.children().filter((_, option) => !filters[option.value]).detach(); $select.select2({ data: Object.keys(filters), tags: true, @@ -40,7 +42,7 @@ $(() => { for (const [name, value] of Object.entries(preset)) { $form.find(`.form--filter[name=${name}]`).val(value); } - }); + }) } initializeSelect(); From 49a089d4fb19ee300626c6df4ed5d9dc669a1cdf Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 14 Sep 2022 19:24:31 -0700 Subject: [PATCH 043/968] Enable/disable save and delete buttons on the fly --- app/assets/javascripts/filters.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 2fa6b8771..f3356b7b6 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -35,23 +35,27 @@ $(() => { const preset = filters[filterName]; // Name is not one of the presets, i.e user is creating a new preset - if (!preset) { return; } - + if (!preset) { + $saveButton.prop('disabled', false); + $deleteButton.prop('disabled', true); + return; + } $saveButton.prop('disabled', true); + $deleteButton.prop('disabled', preset.system); for (const [name, value] of Object.entries(preset)) { $form.find(`.form--filter[name=${name}]`).val(value); } - }) + }); + $saveButton.prop('disabled', true); + $deleteButton.prop('disabled', true); } initializeSelect(); // Enable saving when the filter is changed - $form.find('.form--filter').each((i, filter) => { - $(filter).on('change', _ => { - $saveButton.prop('disabled', false); - }); + $form.find('.form--filter').on('change', _ => { + $saveButton.prop('disabled', false); }); $saveButton.on('click', async evt => { @@ -67,6 +71,7 @@ $(() => { // Reinitialize to get new options await initializeSelect(); $saveButton.prop('disabled', true); + $deleteButton.prop('disabled', false); }); $deleteButton?.on('click', async evt => { @@ -75,12 +80,15 @@ $(() => { // Reinitialize to get new options await initializeSelect(); $saveButton.prop('disabled', true); + $deleteButton.prop('disabled', true); } }); $form.find('.filter-clear').on('click', _ => { $select.val(null).trigger('change'); $form.find('.form--filter').val(null).trigger('change'); + $saveButton.prop('disabled', true); + $deleteButton.prop('disabled', true); }); }); }); \ No newline at end of file From 961fa7aa4256a773a9cd085c5c980ddbe9feb24a Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 15 Sep 2022 20:07:39 +0200 Subject: [PATCH 044/968] Add SAML support - Add devise_saml_authenticatable - Add saml_authenticatable to user - Add SamlInit concern + attribute map - Add devise config for SAML - Add site settings for SAML - Add SsoProfile model - Add conditional SAML routes --- Gemfile | 1 + Gemfile.lock | 7 + app/models/concerns/saml_init.rb | 128 ++++++++++++++++++ app/models/sso_profile.rb | 5 + app/models/user.rb | 3 +- config/attribute-map.yml | 18 +++ config/initializers/devise.rb | 74 ++++++++++ config/initializers/devise_example.rb | 71 ++++++++++ config/routes.rb | 3 +- .../20220811131155_create_sso_profile.rb | 8 ++ db/schema.rb | 7 + db/seeds/site_settings.yml | 18 +++ 12 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 app/models/concerns/saml_init.rb create mode 100644 app/models/sso_profile.rb create mode 100644 config/attribute-map.yml create mode 100644 db/migrate/20220811131155_create_sso_profile.rb diff --git a/Gemfile b/Gemfile index 50287ffa7..131ac092c 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'tzinfo-data', '~> 1.2022.3' # Sign in gem 'devise', '~> 4.8' +gem 'devise_saml_authenticatable', '~> 1.9' gem 'omniauth', '~> 2.1' # Markdown support in both directions. diff --git a/Gemfile.lock b/Gemfile.lock index 429090e1c..7f2f852e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,6 +126,9 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise_saml_authenticatable (1.9.0) + devise (> 2.0.0) + ruby-saml (~> 1.7) diffy (3.4.2) digest (3.1.0) docile (1.4.0) @@ -296,6 +299,9 @@ GEM rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) ruby-progressbar (1.11.0) + ruby-saml (1.14.0) + nokogiri (>= 1.10.5) + rexml ruby-vips (2.1.4) ffi (~> 1.12) sass-rails (6.0.0) @@ -374,6 +380,7 @@ DEPENDENCIES counter_culture (~> 3.2) coveralls (~> 0.8) devise (~> 4.8) + devise_saml_authenticatable (~> 1.9) diffy (~> 3.4) e2mmap (~> 0.1) fastimage (~> 2.2) diff --git a/app/models/concerns/saml_init.rb b/app/models/concerns/saml_init.rb new file mode 100644 index 000000000..3709b87ec --- /dev/null +++ b/app/models/concerns/saml_init.rb @@ -0,0 +1,128 @@ +# Module for saml based initalization. +# +# The saml_init_email method is used to initialize the email address after a successful SSO sign in. +# The saml_init_identifier method is used to +module SamlInit + extend ActiveSupport::Concern + + included do + has_one :sso_profile, required: false, autosave: true, dependent: :destroy + + before_validation :prepare_from_saml, if: -> { saml_identifier.present? } + end + + # ----------------------------------------------------------------------------------------------- + # Identifier + # ----------------------------------------------------------------------------------------------- + + # @return [String, Nil] the saml_identifier of this user, or nil if the user is not from SSO + def saml_identifier + sso_profile&.saml_identifier + end + + # @param saml_identifier [String, Nil] sets (or clears) the saml_identifier of this user + def saml_identifier=(saml_identifier) + if saml_identifier.nil? + sso_profile&.destroy + else + build_sso_profile if sso_profile.nil? + sso_profile.saml_identifier = saml_identifier + end + end + + # This method is added as a fallback to support the Single Logout Service. + # + # @return [String, Nil] the saml_identifier of this user, or nil if the user is not from SSO + # @see #saml_identifier + def saml_init_identifier + saml_identifier + end + + # Sets the saml_identifier to the given saml_identifier upon initialization. In contrast to + # #saml_identifier=, this method does not delete the SSO profile in case the saml_identifier is + # not present (safety in case of SSO issues). + # + # @param saml_identifier [String, Nil] the saml_identifier + # @return [String, Nil] the saml_identifier of this user, should never be nil + def saml_init_identifier=(saml_identifier) + build_sso_profile if sso_profile.nil? + + # Only update if non-empty + sso_profile.saml_identifier = saml_identifier if saml_identifier.present? + end + + # ----------------------------------------------------------------------------------------------- + # Email + # ----------------------------------------------------------------------------------------------- + + # This method is added as a fallback to support the Single Logout Service. + # @return [String, Nil] the email address of this user, or nil if the user is not from SSO + def saml_init_email + return nil if sso_profile.nil? + + email + end + + # Initializes email address, and prevents (re)confirmation in case it is changed. + # + # @param email [String] the email address + def saml_init_email=(email) + self.email = email + skip_confirmation! + skip_reconfirmation! + end + + # ----------------------------------------------------------------------------------------------- + # Email is identifier + # ----------------------------------------------------------------------------------------------- + + # Used in the case that email is the unique identifier from saml. + # @return [String, Nil] the email address of the user, or nil in the case the user is not from SSO + def saml_init_email_and_identifier + return nil if sso_profile.nil? + + email + end + + # Used in the case that email is the unique identifier from saml. + # + # @param email [String] the email address (and saml identifier) + def saml_init_email_and_identifier=(email) + self.saml_init_email = email + self.saml_init_identifier = email + end + + # ----------------------------------------------------------------------------------------------- + # Username + # ----------------------------------------------------------------------------------------------- + + # This method is added as fallback to support the Single Logout Service. + # @return [String] the username + def saml_init_username_no_update + username + end + + # Sets the username from SAML in case it was not already set. + # This prevents overriding the user set username with the one from SAML all the time, while + # allowing for email updates to be applied. + # + # @param username [String] the username to set + def saml_init_username_no_update=(username) + self.username = username unless self.username.present? + end + + # ----------------------------------------------------------------------------------------------- + # Creation + # ----------------------------------------------------------------------------------------------- + + protected + + # Prepare a (potentially) new user from saml for creation. If the user is actually new, a random + # password is created for them and email confirmation is skipped. + def prepare_from_saml + return unless new_record? + + self.password = SecureRandom.hex + skip_confirmation! + end +end diff --git a/app/models/sso_profile.rb b/app/models/sso_profile.rb new file mode 100644 index 000000000..0559a0f0f --- /dev/null +++ b/app/models/sso_profile.rb @@ -0,0 +1,5 @@ +class SsoProfile < ApplicationRecord + belongs_to :user, inverse_of: :sso_profile + + validates :saml_identifier, uniqueness: true, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 84d79db01..b9efb8879 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,10 +2,11 @@ # application code (i.e. excluding Devise) is concerned, has many questions, answers, and votes. class User < ApplicationRecord include ::UserMerge + include ::SamlInit devise :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable, :trackable, :validatable, - :lockable, :omniauthable + :lockable, :omniauthable, :saml_authenticatable has_many :posts, dependent: :nullify has_many :votes, dependent: :destroy diff --git a/config/attribute-map.yml b/config/attribute-map.yml new file mode 100644 index 000000000..c99ff263a --- /dev/null +++ b/config/attribute-map.yml @@ -0,0 +1,18 @@ +# Add your SAML attribute mapping here. +# +# Required: +# '': 'saml_init_email' +# '': 'saml_init_identifier' +# '': 'username' (user cannot change it) OR 'saml_init_username_no_update' (only set on first login, user can change) +# +# If email is the unique identifier, map the email attribute to 'saml_init_email_and_identifier' instead of the above. +# In that case you also need to update devise.rb and set `config.saml_default_user_key = :'saml_init_email_and_identifier'`. +# +'uid': 'saml_init_identifier' +'urn:mace:dir:attribute-def:uid': 'saml_init_identifier' +'mail': 'saml_init_email' +'urn:mace:dir:attribute-def:mail': 'saml_init_email' +'email': 'saml_init_email' +'urn:mace:dir:attribute-def:email': 'saml_init_email' +'displayName': 'saml_init_username_no_update' +'urn:mace:dir:attribute-def': 'saml_init_username_no_update' diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 2b2af4c05..9eadc984e 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -270,4 +270,78 @@ # When using OmniAuth, Devise cannot automatically set OmniAuth path, # so you need to do it manually. For the users scope, it would be: # config.omniauth_path_prefix = '/my_engine/users/auth' + + # Reference https://github.com/apokalipto/devise_saml_authenticatable + # ==> Configuration for :saml_authenticatable + + # Add prefix to saml routes + config.saml_route_helper_prefix = 'saml' + + # Create user if the user does not exist. + config.saml_create_user = true + + # Update the attributes of the user after a successful login. Set this to + # false if you don't want to update the users email address from saml when it + # is changed. + config.saml_update_user = true + + # Set the default user key. The user will be looked up by this key. Make + # sure that the (mapped) Authentication Response includes the attribute. + # + # In the attribute map we register this as saml_init_identifier + # (the method to call), so this is also how we have to refer to it here. + config.saml_default_user_key = :'saml_init_identifier' + + # Load by sso_profile instead of by email address. + config.saml_resource_locator = Proc.new do |model, saml_response, auth_value| + # You can use saml_response here to access other attributes than the saml_default_user_key indicated above if need be. + SsoProfile.includes(:user).find_by(saml_identifier: auth_value)&.user + end + + # Optional. This stores the session index defined by the IDP during login. If provided it will be used as a salt + # for the user's session to facilitate an IDP initiated logout request. + config.saml_session_index_key = :session_index + + # You can set this value to use Subject or SAML assertation as info to which email will be compared. + # If you don't set it then email will be extracted from SAML assertation attributes. + config.saml_use_subject = false + + # You can support multiple IdPs by setting this value to a class that implements a #settings method which takes + # an IdP entity id as an argument and returns a hash of idp settings for the corresponding IdP. + config.idp_settings_adapter = nil + + # You provide you own method to find the idp_entity_id in a SAML message in the case of multiple IdPs + # by setting this to a custom reader class, or use the default. + # config.idp_entity_id_reader = DeviseSamlAuthenticatable::DefaultIdpEntityIdReader + + # You can set a handler object that takes the response for a failed SAML request and the strategy, + # and implements a #handle method. This method can then redirect the user, return error messages, etc. + # config.saml_failed_callback = nil + + # Add your SAML configuration from your IDP here. + # + # Attributes must be mapped in the attribute-map.yml + # + # For certificates and keys, you can use + # File.read('path/to/certificate') + # instead of providing the certificate/key in a string. + # + # config.saml_configure do |settings| + # settings.assertion_consumer_service_url = '/users/saml/auth' + # settings.assertion_consumer_service_binding = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + # settings.name_identifier_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' + # settings.security[:want_assertions_signed] = true + # settings.security[:metadata_signed] = true + # settings.security[:authn_requests_signed] = true + # settings.force_authn = !Rails.env.production? + # settings.protocol_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + # settings.passive = false + # settings.issuer = '/users/saml/metadata' + # settings.idp_slo_target_url = '' + # settings.idp_sso_target_url = '' + # settings.idp_entity_id = '' + # settings.idp_cert = '' + # settings.certificate = '' + # settings.private_key = '' + # end end diff --git a/config/initializers/devise_example.rb b/config/initializers/devise_example.rb index ef47ca5e1..12729faf1 100644 --- a/config/initializers/devise_example.rb +++ b/config/initializers/devise_example.rb @@ -265,4 +265,75 @@ # When using OmniAuth, Devise cannot automatically set OmniAuth path, # so you need to do it manually. For the users scope, it would be: # config.omniauth_path_prefix = '/my_engine/users/auth' + + # Reference https://github.com/apokalipto/devise_saml_authenticatable + # ==> Configuration for :saml_authenticatable + + # Add prefix to saml routes + config.saml_route_helper_prefix = 'saml' + + # Create user if the user does not exist. + config.saml_create_user = true + + # Update the attributes of the user after a successful login. + config.saml_update_user = true + + # Set the default user key. The user will be looked up by this key. Make + # sure that the Authentication Response includes the attribute. + # + # In the attribute map we register this as init_saml_identifier (the method to call), so this is also how we have to refer to it here. + config.saml_default_user_key = :'init_saml_identifier' + + # Load by sso_profile instead of by email address. + config.saml_resource_locator = Proc.new do |model, saml_response, auth_value| + # You can use saml_response here to access other attributes than the saml_default_user_key indicated above if need be. + SsoProfile.includes(:user).find_by(saml_identifier: auth_value)&.user + end + + # Optional. This stores the session index defined by the IDP during login. If provided it will be used as a salt + # for the user's session to facilitate an IDP initiated logout request. + config.saml_session_index_key = :session_index + + # You can set this value to use Subject or SAML assertation as info to which email will be compared. + # If you don't set it then email will be extracted from SAML assertation attributes. + config.saml_use_subject = false + + # You can support multiple IdPs by setting this value to a class that implements a #settings method which takes + # an IdP entity id as an argument and returns a hash of idp settings for the corresponding IdP. + config.idp_settings_adapter = nil + + # You provide you own method to find the idp_entity_id in a SAML message in the case of multiple IdPs + # by setting this to a custom reader class, or use the default. + # config.idp_entity_id_reader = DeviseSamlAuthenticatable::DefaultIdpEntityIdReader + + # You can set a handler object that takes the response for a failed SAML request and the strategy, + # and implements a #handle method. This method can then redirect the user, return error messages, etc. + # config.saml_failed_callback = nil + + # Add your SAML configuration from your IDP here. + # + # Attributes must be mapped in the attribute-map.yml + # + # For certificates and keys, you can use + # File.read('path/to/certificate') + # instead of providing the certificate/key in a string. + # + # config.saml_configure do |settings| + # settings.assertion_consumer_service_url = '/users/saml/auth' + # settings.assertion_consumer_service_binding = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + # settings.name_identifier_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' + # settings.security[:want_assertions_signed] = true + # settings.security[:metadata_signed] = true + # settings.security[:authn_requests_signed] = true + # settings.force_authn = !Rails.env.production? + # settings.protocol_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + # settings.passive = false + # settings.issuer = '/users/saml/metadata' + # settings.idp_slo_target_url = '' + # settings.idp_sso_target_url = '' + # settings.idp_entity_id = '' + # settings.idp_cert = '' + # settings.certificate = '' + # settings.private_key = '' + # end end diff --git a/config/routes.rb b/config/routes.rb index 1619878ee..65e47fa80 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ Rails.application.routes.draw do - devise_for :users, controllers: { sessions: 'users/sessions', registrations: 'users/registrations' } + devise_for :users, controllers: { sessions: 'users/sessions', registrations: 'users/registrations' }, skip: :saml_authenticatable + devise_for :users, only: :saml_authenticatable, constraints: { url: lambda { |_url| SiteSetting['SsoSignIn'] } } devise_scope :user do get 'users/2fa/login', to: 'users/sessions#verify_2fa', as: :login_verify_2fa post 'users/2fa/login', to: 'users/sessions#verify_code', as: :login_verify_code diff --git a/db/migrate/20220811131155_create_sso_profile.rb b/db/migrate/20220811131155_create_sso_profile.rb new file mode 100644 index 000000000..1ef086211 --- /dev/null +++ b/db/migrate/20220811131155_create_sso_profile.rb @@ -0,0 +1,8 @@ +class CreateSsoProfile < ActiveRecord::Migration[5.2] + def change + create_table :sso_profiles do |t| + t.string :saml_identifier, null: false + t.references :user, null: false, foreign_key: true + end + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index 9cc021c6d..cc27c5cbc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -535,6 +535,12 @@ t.index ["name"], name: "index_site_settings_on_name" end + create_table "sso_profiles", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "saml_identifier", null: false + t.bigint "user_id", null: false + t.index ["user_id"], name: "index_sso_profiles_on_user_id" + end + create_table "subscriptions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "type", null: false t.string "qualifier" @@ -755,6 +761,7 @@ add_foreign_key "posts", "users", column: "locked_by_id" add_foreign_key "privileges", "communities" add_foreign_key "site_settings", "communities" + add_foreign_key "sso_profiles", "users" add_foreign_key "subscriptions", "communities" add_foreign_key "subscriptions", "users" add_foreign_key "suggested_edits", "communities" diff --git a/db/seeds/site_settings.yml b/db/seeds/site_settings.yml index cb91ffc3b..ef70d3fe0 100644 --- a/db/seeds/site_settings.yml +++ b/db/seeds/site_settings.yml @@ -515,3 +515,21 @@ description: > The content of a post is shown in short in lists (e.g. category post overview or in search). This setting controls how many characters of a post are shown. + +- name: SsoSignIn + value: false + value_type: boolean + community_id: ~ + category: SignInAndSignUp + description: > + Whether to enable SSO Sign in. If enabled, this replaces normal Sign In and Sign Up unless if Mixed Sign In is enabled. + NOTE: This requires a SAML provider to be configured and set up. + +- name: MixedSignIn + value: false + value_type: boolean + community_id: ~ + category: SignInAndSignUp + description: > + This setting only has an effect when SSO Sign In is enabled. Enables mixed sign in: both signing in and signing up as well as SSO sign in are enabled. + If disabled, only one sign in method is enabled. From 9219328bdd93a4ae878a407add93300f1b75e4c3 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Fri, 19 Aug 2022 16:55:55 +0200 Subject: [PATCH 045/968] Fix routes and update links in UI --- app/controllers/application_controller.rb | 6 ++++- app/views/devise/shared/_links.html.erb | 17 ++++++++----- app/views/layouts/_header.html.erb | 30 ++++++++++++++++++----- app/views/mod_warning/current.html.erb | 18 +++++++++++--- config/routes.rb | 11 +++++++-- 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 68cd945fb..d1e241ee5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -345,7 +345,11 @@ def authenticate_user!(_fav = nil, **_opts) respond_to do |format| format.html do flash[:error] = 'You need to sign in or sign up to continue.' - redirect_to new_user_session_path + if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] + redirect_to new_user_session_path + else + redirect_to new_saml_user_session_path + end end format.json do render json: { error: 'You need to sign in or sign up to continue.' }, status: 401 diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index 18fdcd6ab..a7bbfc0d9 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -1,20 +1,25 @@ -<%- if controller_name != 'sessions' %> - <%= link_to "Sign in", new_session_path(resource_name) %>
+<%- if controller_name != 'sessions' && controller_name != 'saml_sessions' %> + <%- if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%= link_to "Sign in", new_session_path(resource_name) %>
+ <% end %> + <%- if SiteSetting['SsoSignIn'] %> + <%= link_to "SSO Sign in", new_saml_session_path(resource_name) %>
+ <% end %> <% end %> -<%- if devise_mapping.registerable? && controller_name != 'registrations' %> +<%- if devise_mapping.registerable? && controller_name != 'registrations' && (SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn']) %> <%= link_to "Sign up", new_registration_path(resource_name) %>
<% end %> -<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' && (SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn']) %> <%= link_to "Forgot your password?", new_password_path(resource_name) %>
<% end %> -<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' && (SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn']) %> <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
<% end %> -<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' && (SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn']) %> <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
<% end %> diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 0de78a20d..6b56abfcb 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -69,8 +69,13 @@ <% unless user_signed_in? %>
- <%= link_to 'Sign Up', new_user_registration_path, class: 'button is-muted is-filled' %> - <%= link_to 'Sign In', new_user_session_path, class: 'button is-muted is-outlined' %> + <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%= link_to 'Sign Up', new_user_registration_path, class: 'button is-muted is-filled' %> + <%= link_to 'Sign In', new_user_session_path, class: 'button is-muted is-outlined' %> + <% end %> + <% if SiteSetting['SsoSignIn'] %> + <%= link_to 'SSO Sign In', new_saml_user_session_path, class: 'button is-muted is-outlined' %> + <% end %>
<% end %> <% unless @community.is_fake %> @@ -88,7 +93,11 @@
<% if user_signed_in? %> - <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-muted has-float-right' %> + <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-muted has-float-right' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-muted has-float-right' %> + <% end %> <% end %>

Communities

@@ -182,10 +191,19 @@ <% end %> <% end %> <% end %> - <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'menu--item' %> + <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'menu--item' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'menu--item' %> + <% end %> <% else %> - <%= link_to 'Sign In', new_user_session_path, class: 'menu--item' %> - <%= link_to 'Sign Up', new_user_registration_path, class: 'menu--item' %> + <% if SiteSetting['SsoSignIn'] %> + <%= link_to 'SSO Sign In', new_saml_user_session_path, class: 'menu--item' %> + <% end %> + <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%= link_to 'Sign In', new_user_session_path, class: 'menu--item' %> + <%= link_to 'Sign Up', new_user_registration_path, class: 'menu--item' %> + <% end %> <% end %>
diff --git a/app/views/mod_warning/current.html.erb b/app/views/mod_warning/current.html.erb index c98f4bf93..fa2a76017 100644 --- a/app/views/mod_warning/current.html.erb +++ b/app/views/mod_warning/current.html.erb @@ -10,7 +10,11 @@ <%= raw(sanitize(@warning.body_as_html, scrubber: scrubber)) %>

Your account has been temporarily suspended (ends in <%= time_ago_in_words(current_user.community_user.suspension_end) %>). We look forward to your return and continued contributions to the site after this period. In the event of continued rule violations after this period, your account may be suspended for longer periods. If you have any questions about this suspension or would like to dispute it, contact us.

- <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% end %>
<% else %>
@@ -27,7 +31,11 @@ <%= submit_tag 'Continue', class: 'button is-filled' %> - <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% end %> <% end %>
<% end %> @@ -46,7 +54,11 @@ <%= submit_tag 'Continue', class: 'button is-filled' %> - <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% end %> <% end %> <% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 65e47fa80..93c150300 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,13 @@ Rails.application.routes.draw do - devise_for :users, controllers: { sessions: 'users/sessions', registrations: 'users/registrations' }, skip: :saml_authenticatable - devise_for :users, only: :saml_authenticatable, constraints: { url: lambda { |_url| SiteSetting['SsoSignIn'] } } + # All devise routes except for the usual ones + devise_for :users, skip: %i[sessions registrations confirmations unlock passwords saml_authenticatable] + # Add normal sign in/sign up, confirmations, registrations, unlocking and password editing routes only if no SSO or mixed. + devise_for :users, only: %i[sessions registrations confirmations unlock passwords], + controllers: { sessions: 'users/sessions', registrations: 'users/registrations' }, + constraints: { url: lambda { |_url| SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] } } + # Add SAML routes only when SAML enabled + devise_for :users, only: :saml_authenticatable, + constraints: { url: lambda { |_url| SiteSetting['SsoSignIn'] } } devise_scope :user do get 'users/2fa/login', to: 'users/sessions#verify_2fa', as: :login_verify_2fa post 'users/2fa/login', to: 'users/sessions#verify_code', as: :login_verify_code From 135a459149c71e2b62266a173a086c32bca4c8e3 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Fri, 19 Aug 2022 23:20:44 +0200 Subject: [PATCH 046/968] Add 2FA support --- .../users/saml_sessions_controller.rb | 41 +++++++++++++++++++ app/controllers/users/sessions_controller.rb | 9 +++- config/routes.rb | 7 ++-- 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 app/controllers/users/saml_sessions_controller.rb diff --git a/app/controllers/users/saml_sessions_controller.rb b/app/controllers/users/saml_sessions_controller.rb new file mode 100644 index 000000000..46c3108dd --- /dev/null +++ b/app/controllers/users/saml_sessions_controller.rb @@ -0,0 +1,41 @@ +class Users::SamlSessionsController < Devise::SamlSessionsController + # This method is almost the same code as the Users::SessionsController#create, and any changes + # made here should probably also be applied over there. + def create + super do |user| + if user.deleted? + # The IDP already confirmed the sign in, so we can't fool the user any more that their credentials were incorrect. + sign_out user + flash[:notice] = nil + flash[:danger] = 'We could not sign you in because of an issue with your account.' + redirect_to root_path + return + end + + if user.community_user&.deleted? + sign_out user + flash[:notice] = nil + flash[:danger] = 'Your profile on this community has been deleted.' + redirect_to root_path + return + end + + if user.present? && user.enabled_2fa + sign_out user + case user.two_factor_method + when 'app' + id = user.id + Users::SessionsController.first_factor << id + redirect_to login_verify_2fa_path(uid: id) + return + when 'email' + TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now + flash[:notice] = nil + flash[:info] = 'Please check your email inbox for a link to sign in.' + redirect_to root_path + return + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index da34a1d18..43190cc5b 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -1,8 +1,9 @@ class Users::SessionsController < Devise::SessionsController protect_from_forgery except: [:create] - @@first_factor = [] + mattr_accessor :first_factor, default: [], instance_writer: false, instance_reader: false + # Any changes made here should also be made to the Users::SamlSessionsController. def create super do |user| if user.deleted? @@ -60,7 +61,11 @@ def verify_code else AuditLog.user_history(event_type: 'two_factor_fail', related: target_user, comment: 'first factor not present') flash[:danger] = "You haven't entered your password yet." - redirect_to new_session_path(target_user) + if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] + redirect_to new_session_path(target_user) + else + redirect_to new_saml_session_path(target_user) + end end else AuditLog.user_history(event_type: 'two_factor_fail', related: target_user, comment: 'wrong code') diff --git a/config/routes.rb b/config/routes.rb index 93c150300..a7c1997f0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,13 +1,14 @@ Rails.application.routes.draw do - # All devise routes except for the usual ones - devise_for :users, skip: %i[sessions registrations confirmations unlock passwords saml_authenticatable] - # Add normal sign in/sign up, confirmations, registrations, unlocking and password editing routes only if no SSO or mixed. + # Add normal sign in/sign up, confirmations, registrations, unlocking and password editing routes only if no SSO or mixed sign in. devise_for :users, only: %i[sessions registrations confirmations unlock passwords], controllers: { sessions: 'users/sessions', registrations: 'users/registrations' }, constraints: { url: lambda { |_url| SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] } } # Add SAML routes only when SAML enabled devise_for :users, only: :saml_authenticatable, + controllers: { saml_sessions: 'users/saml_sessions' }, constraints: { url: lambda { |_url| SiteSetting['SsoSignIn'] } } + # Add any other devise routes that may exist that we did not add yet + devise_for :users, skip: %i[sessions registrations confirmations unlock passwords saml_authenticatable] devise_scope :user do get 'users/2fa/login', to: 'users/sessions#verify_2fa', as: :login_verify_2fa post 'users/2fa/login', to: 'users/sessions#verify_code', as: :login_verify_code From 2b4f738b92ff137a911ae9d732e1f93f8870a8ef Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Tue, 23 Aug 2022 10:25:07 +0200 Subject: [PATCH 047/968] Apply code suggestions from ArtOfCode --- app/controllers/application_controller.rb | 10 +++++++++- app/controllers/users/saml_sessions_controller.rb | 10 +--------- app/controllers/users/sessions_controller.rb | 2 +- app/views/layouts/_header.html.erb | 12 ++++++------ app/views/mod_warning/current.html.erb | 6 +++--- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d1e241ee5..902a66fea 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -340,12 +340,20 @@ def user_signed_in? helpers.user_signed_in? end + def sso_sign_in_enabled? + SiteSetting['SsoSignIn'] + end + + def devise_sign_in_enabled? + SiteSetting['MixedSignIn'] || !sso_sign_in_enabled? + end + def authenticate_user!(_fav = nil, **_opts) unless user_signed_in? respond_to do |format| format.html do flash[:error] = 'You need to sign in or sign up to continue.' - if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] + if devise_sign_in_enabled? redirect_to new_user_session_path else redirect_to new_saml_user_session_path diff --git a/app/controllers/users/saml_sessions_controller.rb b/app/controllers/users/saml_sessions_controller.rb index 46c3108dd..24d36d8b1 100644 --- a/app/controllers/users/saml_sessions_controller.rb +++ b/app/controllers/users/saml_sessions_controller.rb @@ -3,7 +3,7 @@ class Users::SamlSessionsController < Devise::SamlSessionsController # made here should probably also be applied over there. def create super do |user| - if user.deleted? + if user.deleted? || user.community_user&.deleted? # The IDP already confirmed the sign in, so we can't fool the user any more that their credentials were incorrect. sign_out user flash[:notice] = nil @@ -12,14 +12,6 @@ def create return end - if user.community_user&.deleted? - sign_out user - flash[:notice] = nil - flash[:danger] = 'Your profile on this community has been deleted.' - redirect_to root_path - return - end - if user.present? && user.enabled_2fa sign_out user case user.two_factor_method diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 43190cc5b..76c81021c 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -61,7 +61,7 @@ def verify_code else AuditLog.user_history(event_type: 'two_factor_fail', related: target_user, comment: 'first factor not present') flash[:danger] = "You haven't entered your password yet." - if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] + if devise_sign_in_enabled? redirect_to new_session_path(target_user) else redirect_to new_saml_session_path(target_user) diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 6b56abfcb..32fa3b3f3 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -69,11 +69,11 @@ <% unless user_signed_in? %>
- <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <% if devise_sign_in_enabled? %> <%= link_to 'Sign Up', new_user_registration_path, class: 'button is-muted is-filled' %> <%= link_to 'Sign In', new_user_session_path, class: 'button is-muted is-outlined' %> <% end %> - <% if SiteSetting['SsoSignIn'] %> + <% if sso_sign_in_enabled? %> <%= link_to 'SSO Sign In', new_saml_user_session_path, class: 'button is-muted is-outlined' %> <% end %>
@@ -93,7 +93,7 @@
<% if user_signed_in? %> - <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <% if devise_sign_in_enabled? %> <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-muted has-float-right' %> <% else %> <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-muted has-float-right' %> @@ -191,16 +191,16 @@ <% end %> <% end %> <% end %> - <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <% if devise_sign_in_enabled? %> <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'menu--item' %> <% else %> <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'menu--item' %> <% end %> <% else %> - <% if SiteSetting['SsoSignIn'] %> + <% if sso_sign_in_enabled? %> <%= link_to 'SSO Sign In', new_saml_user_session_path, class: 'menu--item' %> <% end %> - <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <% if devise_sign_in_enabled? %> <%= link_to 'Sign In', new_user_session_path, class: 'menu--item' %> <%= link_to 'Sign Up', new_user_registration_path, class: 'menu--item' %> <% end %> diff --git a/app/views/mod_warning/current.html.erb b/app/views/mod_warning/current.html.erb index fa2a76017..0e98fe10b 100644 --- a/app/views/mod_warning/current.html.erb +++ b/app/views/mod_warning/current.html.erb @@ -10,7 +10,7 @@ <%= raw(sanitize(@warning.body_as_html, scrubber: scrubber)) %>

Your account has been temporarily suspended (ends in <%= time_ago_in_words(current_user.community_user.suspension_end) %>). We look forward to your return and continued contributions to the site after this period. In the event of continued rule violations after this period, your account may be suspended for longer periods. If you have any questions about this suspension or would like to dispute it, contact us.

- <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <% if devise_sign_in_enabled? %> <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> <% else %> <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> @@ -31,7 +31,7 @@ <%= submit_tag 'Continue', class: 'button is-filled' %> - <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <% if devise_sign_in_enabled? %> <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> <% else %> <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> @@ -54,7 +54,7 @@ <%= submit_tag 'Continue', class: 'button is-filled' %> - <% if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <% if devise_sign_in_enabled? %> <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> <% else %> <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> From f06bfdba611ce92bde78aff9d805258fb519afa1 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Tue, 23 Aug 2022 10:25:30 +0200 Subject: [PATCH 048/968] Fix mistake in attribute map --- config/attribute-map.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/attribute-map.yml b/config/attribute-map.yml index c99ff263a..1f3bc4eea 100644 --- a/config/attribute-map.yml +++ b/config/attribute-map.yml @@ -15,4 +15,4 @@ 'email': 'saml_init_email' 'urn:mace:dir:attribute-def:email': 'saml_init_email' 'displayName': 'saml_init_username_no_update' -'urn:mace:dir:attribute-def': 'saml_init_username_no_update' +'urn:mace:dir:attribute-def:displayName': 'saml_init_username_no_update' From 1257fd25c798fb654bddae6b65a4dc8d09fdb4ab Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Mon, 29 Aug 2022 10:16:41 +0200 Subject: [PATCH 049/968] Use helpers in devise --- app/controllers/application_controller.rb | 4 ++-- app/helpers/users_helper.rb | 8 ++++++++ app/views/devise/shared/_links.html.erb | 12 ++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 902a66fea..ea5216c49 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -341,11 +341,11 @@ def user_signed_in? end def sso_sign_in_enabled? - SiteSetting['SsoSignIn'] + helpers.sso_sign_in_enabled? end def devise_sign_in_enabled? - SiteSetting['MixedSignIn'] || !sso_sign_in_enabled? + helpers.devise_sign_in_enabled? end def authenticate_user!(_fav = nil, **_opts) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 2ebc8c27c..3a89e4ba7 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -51,4 +51,12 @@ def user_link(user, **link_opts) link_to user.rtl_safe_username, user_url(user), { dir: 'ltr' }.merge(link_opts) end end + + def sso_sign_in_enabled? + SiteSetting['SsoSignIn'] + end + + def devise_sign_in_enabled? + SiteSetting['MixedSignIn'] || !sso_sign_in_enabled? + end end diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index a7bbfc0d9..d50e9269f 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -1,25 +1,25 @@ <%- if controller_name != 'sessions' && controller_name != 'saml_sessions' %> - <%- if SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn'] %> + <%- if devise_sign_in_enabled? %> <%= link_to "Sign in", new_session_path(resource_name) %>
<% end %> - <%- if SiteSetting['SsoSignIn'] %> + <%- if sso_sign_in_enabled? %> <%= link_to "SSO Sign in", new_saml_session_path(resource_name) %>
<% end %> <% end %> -<%- if devise_mapping.registerable? && controller_name != 'registrations' && (SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn']) %> +<%- if devise_mapping.registerable? && controller_name != 'registrations' && devise_sign_in_enabled? %> <%= link_to "Sign up", new_registration_path(resource_name) %>
<% end %> -<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' && (SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn']) %> +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' && devise_sign_in_enabled? %> <%= link_to "Forgot your password?", new_password_path(resource_name) %>
<% end %> -<%- if devise_mapping.confirmable? && controller_name != 'confirmations' && (SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn']) %> +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' && devise_sign_in_enabled? %> <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
<% end %> -<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' && (SiteSetting['MixedSignIn'] || !SiteSetting['SsoSignIn']) %> +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' && devise_sign_in_enabled? %> <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
<% end %> From 6b680e5938b8399d67539bfb707ae5be132e351a Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Mon, 29 Aug 2022 10:17:09 +0200 Subject: [PATCH 050/968] Add SSO Sign in button on normal sign in page --- app/views/devise/sessions/new.html.erb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index a15f72206..1dde5e978 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -5,6 +5,13 @@ Codidact communities.
+<% if sso_sign_in_enabled? %> +
+ You can also sign in with your Single Sign-On provider. + <%= link_to "SSO Sign in", new_saml_session_path(resource_name), class: 'button is-primary is-filled' %> +
+<% end %> + <%= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| %>
<%= f.label :email, class: "form-element" %> From a9e7d239358521c660914060c0498dad47898b25 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Mon, 29 Aug 2022 13:06:19 +0200 Subject: [PATCH 051/968] Fix links to SAML --- app/controllers/users/sessions_controller.rb | 2 +- app/views/devise/sessions/new.html.erb | 12 ++++++------ app/views/devise/shared/_links.html.erb | 13 ++++++------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 76c81021c..6f199bfbe 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -64,7 +64,7 @@ def verify_code if devise_sign_in_enabled? redirect_to new_session_path(target_user) else - redirect_to new_saml_session_path(target_user) + redirect_to new_saml_user_session_path(target_user) end end else diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 1dde5e978..2ba98c178 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -1,17 +1,17 @@

Sign in

-
- Your sign-in information is the same on all - Codidact communities. -
- <% if sso_sign_in_enabled? %>
You can also sign in with your Single Sign-On provider. - <%= link_to "SSO Sign in", new_saml_session_path(resource_name), class: 'button is-primary is-filled' %> + <%= link_to "SSO Sign in", new_saml_user_session_path(resource_name), class: 'button is-primary is-filled' %>
<% end %> +
+ Your sign-in information is the same on all + Codidact communities. +
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| %>
<%= f.label :email, class: "form-element" %> diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index d50e9269f..15b79838a 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -1,10 +1,9 @@ -<%- if controller_name != 'sessions' && controller_name != 'saml_sessions' %> - <%- if devise_sign_in_enabled? %> - <%= link_to "Sign in", new_session_path(resource_name) %>
- <% end %> - <%- if sso_sign_in_enabled? %> - <%= link_to "SSO Sign in", new_saml_session_path(resource_name) %>
- <% end %> +<%- if controller_name != 'sessions' && devise_sign_in_enabled? %> + <%= link_to "Sign in", new_session_path(resource_name) %>
+<% end %> + +<%- if controller_name != 'saml_sessions' && sso_sign_in_enabled? %> + <%= link_to "SSO Sign in", new_saml_user_session_path(resource_name) %>
<% end %> <%- if devise_mapping.registerable? && controller_name != 'registrations' && devise_sign_in_enabled? %> From 0bdd4d4772694c1519ea58e5d7a4e5811ae2af52 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Mon, 29 Aug 2022 13:06:48 +0200 Subject: [PATCH 052/968] Add sign in with sso notice on registrations page --- app/controllers/users/saml_sessions_controller.rb | 2 +- app/views/devise/registrations/new.html.erb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/controllers/users/saml_sessions_controller.rb b/app/controllers/users/saml_sessions_controller.rb index 24d36d8b1..afd2587d5 100644 --- a/app/controllers/users/saml_sessions_controller.rb +++ b/app/controllers/users/saml_sessions_controller.rb @@ -30,4 +30,4 @@ def create end end end -end \ No newline at end of file +end diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 1679be35b..8fc1a62a7 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -1,5 +1,12 @@

Sign up

+<% if sso_sign_in_enabled? %> +
+ You can also sign in with your Single Sign-On provider. + <%= link_to "SSO Sign in", new_saml_user_session_path(resource_name), class: 'button is-primary is-filled' %> +
+<% end %> +
If you have an account on another Codidact site, don't create a new account here - <%= link_to 'sign in', new_user_session_path %> with your existing details instead. From 0ee3045a647c039ad7878ce68c7675aed729eaf1 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 15 Sep 2022 20:23:57 +0200 Subject: [PATCH 053/968] Add initial scaffolding for tag synonyms Adds tag synonyms into the search for tags --- app/models/tag.rb | 10 +++++++--- app/models/tag_synonym.rb | 3 +++ db/migrate/20220915181608_create_tag_synonyms.rb | 9 +++++++++ db/schema.rb | 12 +++++++++++- test/fixtures/tag_synonyms.yml | 11 +++++++++++ test/models/tag_synonym_test.rb | 7 +++++++ 6 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 app/models/tag_synonym.rb create mode 100644 db/migrate/20220915181608_create_tag_synonyms.rb create mode 100644 test/fixtures/tag_synonyms.yml create mode 100644 test/models/tag_synonym_test.rb diff --git a/app/models/tag.rb b/app/models/tag.rb index 1b78a95fe..e53e6f7b6 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -5,6 +5,7 @@ class Tag < ApplicationRecord has_many :children, class_name: 'Tag', foreign_key: :parent_id has_many :children_with_paths, class_name: 'TagWithPath', foreign_key: :parent_id has_many :post_history_tags + has_many :tag_synonyms, dependent: :destroy belongs_to :tag_set belongs_to :parent, class_name: 'Tag', optional: true @@ -16,9 +17,12 @@ class Tag < ApplicationRecord validates :name, uniqueness: { scope: [:tag_set_id], case_sensitive: false } def self.search(term) - where('name LIKE ?', "%#{sanitize_sql_like(term)}%") - .or(where('excerpt LIKE ?', "%#{sanitize_sql_like(term)}%")) - .order(Arel.sql(sanitize_sql_array(['name LIKE ? DESC, name', "#{sanitize_sql_like(term)}%"]))) + base = joins(:tag_synonyms) + base.where('name LIKE ?', "%#{sanitize_sql_like(term)}%") + .or(base.where('excerpt LIKE ?', "%#{sanitize_sql_like(term)}%")) + .or(base.where('tag_synonyms.name LIKE ?', "%#{sanitize_sql_like(term)}%")) + .distinct + .order(Arel.sql(sanitize_sql_array(['name LIKE ? DESC, tag_synonyms.name LIKE ? DESC, name', "#{sanitize_sql_like(term)}%"]))) end def all_children diff --git a/app/models/tag_synonym.rb b/app/models/tag_synonym.rb new file mode 100644 index 000000000..d0b38117c --- /dev/null +++ b/app/models/tag_synonym.rb @@ -0,0 +1,3 @@ +class TagSynonym < ApplicationRecord + belongs_to :tag +end diff --git a/db/migrate/20220915181608_create_tag_synonyms.rb b/db/migrate/20220915181608_create_tag_synonyms.rb new file mode 100644 index 000000000..063f6e9a7 --- /dev/null +++ b/db/migrate/20220915181608_create_tag_synonyms.rb @@ -0,0 +1,9 @@ +class CreateTagSynonyms < ActiveRecord::Migration[7.0] + def change + create_table :tag_synonyms do |t| + t.belongs_to :tag, foreign_key: true + t.string :name, null: false, index: { unique: true } + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 9cc021c6d..c236ac632 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_09_13_183826) do +ActiveRecord::Schema[7.0].define(version: 2022_09_15_181608) do create_table "abilities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "community_id" t.string "name" @@ -594,6 +594,15 @@ t.index ["community_id"], name: "index_tag_sets_on_community_id" end + create_table "tag_synonyms", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "tag_id" + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_tag_synonyms_on_name", unique: true + t.index ["tag_id"], name: "index_tag_synonyms_on_tag_id" + end + create_table "tags", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "name" t.datetime "created_at", precision: nil, null: false @@ -761,6 +770,7 @@ add_foreign_key "suggested_edits", "posts" add_foreign_key "suggested_edits", "users" add_foreign_key "suggested_edits", "users", column: "decided_by_id" + add_foreign_key "tag_synonyms", "tags" add_foreign_key "tags", "communities" add_foreign_key "tags", "tags", column: "parent_id" add_foreign_key "thread_followers", "posts" diff --git a/test/fixtures/tag_synonyms.yml b/test/fixtures/tag_synonyms.yml new file mode 100644 index 000000000..d7a332924 --- /dev/null +++ b/test/fixtures/tag_synonyms.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# This model initially had no columns defined. If you add columns to the +# model remove the "{}" from the fixture names and add the columns immediately +# below each fixture, per the syntax in the comments below +# +one: {} +# column: value +# +two: {} +# column: value diff --git a/test/models/tag_synonym_test.rb b/test/models/tag_synonym_test.rb new file mode 100644 index 000000000..ea074d1e6 --- /dev/null +++ b/test/models/tag_synonym_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class TagSynonymTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From 25c264c4a29b033268b811f4738a095018bb13ae Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 15 Sep 2022 21:03:40 +0200 Subject: [PATCH 054/968] Add display for tag synonyms --- app/views/tags/show.html.erb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/views/tags/show.html.erb b/app/views/tags/show.html.erb index b2350de22..0aba8b951 100644 --- a/app/views/tags/show.html.erb +++ b/app/views/tags/show.html.erb @@ -17,6 +17,14 @@ <% end %> <% end %> +

+<% if @tag.tag_synonyms.any? %> + Synonyms: + <% @tag.tag_synonyms.each do |synonym| %> + <%= synonym.name %> + <% end %> +<% end %> +

<% if @tag.parent_id.present? %> Subtag of <%= link_to @tag.parent.name, tag_path(id: @category.id, tag_id: @tag.parent_id), From 80c6b614e6a4b93e1cc84e46a251899566418dbc Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 15 Sep 2022 21:04:22 +0200 Subject: [PATCH 055/968] Add basic tag synonym controller support --- app/controllers/tags_controller.rb | 2 +- app/models/tag.rb | 1 + app/views/tags/_form.html.erb | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index f1e6dac20..5cc860f71 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -192,7 +192,7 @@ def set_category end def tag_params - params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name) + params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name, tag_synonyms_attributes: [:name]) end def exec(sql_array) diff --git a/app/models/tag.rb b/app/models/tag.rb index e53e6f7b6..934791b4a 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -6,6 +6,7 @@ class Tag < ApplicationRecord has_many :children_with_paths, class_name: 'TagWithPath', foreign_key: :parent_id has_many :post_history_tags has_many :tag_synonyms, dependent: :destroy + accepts_nested_attributes_for :tag_synonyms belongs_to :tag_set belongs_to :parent, class_name: 'Tag', optional: true diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb index f88e1d6d4..c2d08ff66 100644 --- a/app/views/tags/_form.html.erb +++ b/app/views/tags/_form.html.erb @@ -23,6 +23,16 @@

<% end %> +
+ <%= f.label :tag_synonyms, 'Tag Synonyms', class: 'form-element' %> + <%= f.fields_for :tag_synonyms do |tsf| %> +
+ <%= tsf.label :name, 'Name', class: 'form-element' %> + <%= tsf.text_field :name, class: 'form-element' %> +
+ <% end %> +
+
<%= f.label :parent_id, 'Parent tag', class: 'form-element' %> From ff59c3c8d6ffabf084477b6e63904fe0ae48fe62 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 15 Sep 2022 21:34:25 +0200 Subject: [PATCH 056/968] Add synonym display to tag search This required adding synonyms into the json response in the controller. --- app/assets/javascripts/tags.js | 10 +++++++++- app/controllers/tags_controller.rb | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index a2d71685f..60700dd23 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -60,7 +60,7 @@ $(() => { return { results: data.map(t => ({ id: useIds ? t.id : t.name, - text: t.name.replace(//g, '>'), + text: t.name.replace(//g, '>') + convert_synonyms(t.tag_synonyms), desc: t.excerpt })) }; @@ -71,6 +71,14 @@ $(() => { }); }); + function convert_synonyms(synonyms) { + if (synonyms.length === 0) { + return ''; + } else { + return ' (' + synonyms.map((ts) => `${ts.name.replace(//g, '>')}`).join(', ') + ')'; + } + } + $('.js-add-required-tag').on('click', ev => { const $tgt = $(ev.target); const useIds = $tgt.attr('data-use-ids') === 'true'; diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 5cc860f71..53f09b5e1 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -13,10 +13,10 @@ def index (@tag_set&.tags || Tag).search(params[:term]) else (@tag_set&.tags || Tag.all).order(:name) - end.paginate(page: params[:page], per_page: 50) + end.includes(:tag_synonyms).paginate(page: params[:page], per_page: 50) respond_to do |format| format.json do - render json: @tags + render json: @tags.to_json(include: { tag_synonyms: { only: :name } }) end end end From 22828af0efa129984ff64c674d0f213ded09a542 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 15 Sep 2022 21:56:45 +0200 Subject: [PATCH 057/968] Check uniqueness across tags and synonyms Remove uniqueness constraint from the database since it does not consider the community id. --- app/models/tag.rb | 7 +++++++ app/models/tag_synonym.rb | 14 ++++++++++++++ db/migrate/20220915181608_create_tag_synonyms.rb | 2 +- db/schema.rb | 1 - 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/models/tag.rb b/app/models/tag.rb index 934791b4a..40367e612 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -15,6 +15,7 @@ class Tag < ApplicationRecord validates :name, presence: true, format: { with: /[^ \t]+/, message: 'Tag names may not include spaces' } validate :parent_not_self validate :parent_not_own_child + validate :synonym_unique validates :name, uniqueness: { scope: [:tag_set_id], case_sensitive: false } def self.search(term) @@ -59,4 +60,10 @@ def parent_not_own_child errors.add(:base, "The #{parent.name} tag is already a child of this tag.") end end + + def synonym_unique + if TagSynonym.joins(:tag).where(tags: { community_id: community_id }).exists?(name: name) + errors.add(:base, "A tag synonym with the name #{name} already exists.") + end + end end diff --git a/app/models/tag_synonym.rb b/app/models/tag_synonym.rb index d0b38117c..d223ecb20 100644 --- a/app/models/tag_synonym.rb +++ b/app/models/tag_synonym.rb @@ -1,3 +1,17 @@ class TagSynonym < ApplicationRecord belongs_to :tag + has_one :community, through: :tag + + validate :name_unique + + private + + # Checks whether the name of this synonym is not already taken by a tag or synonym in the same community. + def name_unique + if TagSynonym.joins(:tag).where(tags: { community_id: tag.community_id }).exists?(name: name) + errors.add(:base, "A tag synonym with the name #{name} already exists.") + elsif Tag.unscoped.where(community_id: tag.community_id).exists?(name: name) + errors.add(:base, "A tag with the name #{name} already exists.") + end + end end diff --git a/db/migrate/20220915181608_create_tag_synonyms.rb b/db/migrate/20220915181608_create_tag_synonyms.rb index 063f6e9a7..2bf629601 100644 --- a/db/migrate/20220915181608_create_tag_synonyms.rb +++ b/db/migrate/20220915181608_create_tag_synonyms.rb @@ -2,7 +2,7 @@ class CreateTagSynonyms < ActiveRecord::Migration[7.0] def change create_table :tag_synonyms do |t| t.belongs_to :tag, foreign_key: true - t.string :name, null: false, index: { unique: true } + t.string :name, null: false t.timestamps end end diff --git a/db/schema.rb b/db/schema.rb index c236ac632..532f7f074 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -599,7 +599,6 @@ t.string "name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["name"], name: "index_tag_synonyms_on_name", unique: true t.index ["tag_id"], name: "index_tag_synonyms_on_tag_id" end From decc00e837a37e700e913b8ddabf0f39df8e91df Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 15 Sep 2022 21:57:41 +0200 Subject: [PATCH 058/968] Fix synonym searching --- app/models/tag.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/models/tag.rb b/app/models/tag.rb index 40367e612..14227535b 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -19,12 +19,16 @@ class Tag < ApplicationRecord validates :name, uniqueness: { scope: [:tag_set_id], case_sensitive: false } def self.search(term) - base = joins(:tag_synonyms) - base.where('name LIKE ?', "%#{sanitize_sql_like(term)}%") + base = joins(:tag_synonyms).includes(:tag_synonyms) + base.where('tags.name LIKE ?', "%#{sanitize_sql_like(term)}%") .or(base.where('excerpt LIKE ?', "%#{sanitize_sql_like(term)}%")) .or(base.where('tag_synonyms.name LIKE ?', "%#{sanitize_sql_like(term)}%")) .distinct - .order(Arel.sql(sanitize_sql_array(['name LIKE ? DESC, tag_synonyms.name LIKE ? DESC, name', "#{sanitize_sql_like(term)}%"]))) + .order(Arel.sql(sanitize_sql_array([ + 'tags.name LIKE ? DESC, tag_synonyms.name LIKE ? DESC, tags.name', + "#{sanitize_sql_like(term)}%", + "#{sanitize_sql_like(term)}%" + ]))) end def all_children From 9cd55b988045a3c6dd3b579b06240922f87db1ea Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 15 Sep 2022 21:32:24 -0700 Subject: [PATCH 059/968] Show filters on homepage category --- app/views/layouts/_sidebar.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 8c0031860..200040548 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -86,7 +86,7 @@
<% end %> - <% if defined?(@category) && current_page?(category_path(@category)) %> + <% if current_page?(root_path) || defined?(@category) && current_page?(category_path(@category)) %>
Filters From 9bdbc82cddcdd347930d6e505998bc37e7e31a99 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 15 Sep 2022 21:50:26 -0700 Subject: [PATCH 060/968] Fill out selected preset from params --- app/views/search/_filters.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 6e53c3a55..fc745374d 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -5,7 +5,8 @@
<%= label_tag :predefined_filter, 'Predefined Filters', class: "form-element" %> - <%= select_tag :predefined_filter, options_for_select([]), + <%= select_tag :predefined_filter, options_for_select([params[:predefined_filter]], + selected: params[:predefined_filter]), include_blank: true, class: "form-element js-filter-select", id: nil, data: { placeholder: "" } %>
From 61e33c24e25402b2220523387a96781100ae0de5 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 15 Sep 2022 22:25:50 -0700 Subject: [PATCH 061/968] Refactor button enablement logic --- app/assets/javascripts/filters.js | 49 +++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index f3356b7b6..86856b8cd 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -2,9 +2,38 @@ $(() => { $('.js-filter-select').each((_, el) => { const $select = $(el); const $form = $select.closest('form'); + const $formFilters = $form.find('.form--filter'); const $saveButton = $form.find('.filter-save'); const $deleteButton = $form.find('.filter-delete'); + // Enables/Disables Save & Delete buttons programatically + async function computeEnables() { + const filters = await QPixel.filters(); + const filterName = $select.val(); + + // Nothing set + if (!filterName) { + $saveButton.prop('disabled', true); + $deleteButton.prop('disabled', true); + return; + } + + const filter = filters[filterName] + + // New filter + if (!filter) { + $saveButton.prop('disabled', false); + $deleteButton.prop('disabled', true); + return; + } + + // Not a new filter + $deleteButton.prop('disabled', filter.system); + + const hasChanges = [...$formFilters].some(el => filter[el.name] ? filter[el.name] != el.value : el.value); + $saveButton.prop('disabled', filter.system || !hasChanges); + } + async function initializeSelect() { const filters = await QPixel.filters(); @@ -34,29 +63,24 @@ $(() => { const filterName = evt.params.data.id; const preset = filters[filterName]; + computeEnables(); + // Name is not one of the presets, i.e user is creating a new preset if (!preset) { - $saveButton.prop('disabled', false); - $deleteButton.prop('disabled', true); return; } - $saveButton.prop('disabled', true); - $deleteButton.prop('disabled', preset.system); for (const [name, value] of Object.entries(preset)) { $form.find(`.form--filter[name=${name}]`).val(value); } }); - $saveButton.prop('disabled', true); - $deleteButton.prop('disabled', true); + computeEnables(); } initializeSelect(); // Enable saving when the filter is changed - $form.find('.form--filter').on('change', _ => { - $saveButton.prop('disabled', false); - }); + $formFilters.on('change', computeEnables); $saveButton.on('click', async evt => { if (!$form[0].reportValidity()) { return; } @@ -70,8 +94,6 @@ $(() => { await QPixel.setFilter($select.val(), filter); // Reinitialize to get new options await initializeSelect(); - $saveButton.prop('disabled', true); - $deleteButton.prop('disabled', false); }); $deleteButton?.on('click', async evt => { @@ -79,16 +101,13 @@ $(() => { await QPixel.deleteFilter($select.val()); // Reinitialize to get new options await initializeSelect(); - $saveButton.prop('disabled', true); - $deleteButton.prop('disabled', true); } }); $form.find('.filter-clear').on('click', _ => { $select.val(null).trigger('change'); $form.find('.form--filter').val(null).trigger('change'); - $saveButton.prop('disabled', true); - $deleteButton.prop('disabled', true); + computeEnables(); }); }); }); \ No newline at end of file From 41fcb75e50388a12781fa8598b78a296ea79a554 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Fri, 16 Sep 2022 00:10:07 -0700 Subject: [PATCH 062/968] Implement include/exclude filters --- app/helpers/search_helper.rb | 18 +++++++++++++++++- app/views/search/_filters.html.erb | 10 ++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index bc28f8001..596523af2 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -30,7 +30,8 @@ def filters_to_qualifiers valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, status: /any|open|closed/, - numeric: /^[\d.]+$/ + numeric: /^[\d.]+$/, + integer: /^\d+$/ } filter_qualifiers = [] @@ -55,6 +56,14 @@ def filters_to_qualifiers filter_qualifiers.append({ param: :status, value: params[:status] }) end + if params[:include_tags]&.all? { |id| id.match? valid_value[:integer] } + filter_qualifiers.append({ param: :include_tags, tag_ids: params[:include_tags] }) + end + + if params[:exclude_tags]&.all? { |id| id.match? valid_value[:integer] } + filter_qualifiers.append({ param: :exclude_tags, tag_ids: params[:exclude_tags] }) + end + filter_qualifiers end @@ -163,8 +172,15 @@ def qualifiers_to_sql(qualifiers, query) query = query.where("(upvote_count - downvote_count) #{qualifier[:operator]} ?", qualifier[:value]) when :include_tag query = query.where(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) }) + when :include_tags + # Efficiency is... questionable + qualifier[:tag_ids].each do |id| + query = query.where(id: PostsTag.where(tag_id: id).select(:post_id)) + end when :exclude_tag query = query.where.not(posts: { id: PostsTag.where(tag_id: qualifier[:tag_id]).select(:post_id) }) + when :exclude_tags + query = query.where.not(id: PostsTag.where(tag_id: qualifier[:tag_ids]).select(:post_id)) when :category query = query.where("category_id #{qualifier[:operator]} ?", qualifier[:category_id]) when :post_type diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index fc745374d..970784b6e 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -36,6 +36,16 @@ min: 0, step: 1, class: 'form-element form--filter' %>
+
+ <%= label_tag :include_tags, 'Include Tags', class: "form-element" %> + <%= select_tag :include_tags, options_for_select([]), multiple: true, class: 'form-element js-tag-select', + data: { tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %> +
+
+ <%= label_tag :exclude_tags, 'Exclude Tags', class: "form-element" %> + <%= select_tag :exclude_tags, options_for_select([]), multiple: true, class: 'form-element js-tag-select', + data: { tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %> +
<%= label_tag :status, 'Status', class: "form-element" %> <%= select_tag :status, options_for_select(['any', 'open', 'closed'], selected: params[:status] || 'any'), From dcfd82a39e8a05be0142e4a29ca623c6790fd2bd Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Fri, 16 Sep 2022 00:15:06 -0700 Subject: [PATCH 063/968] Fill out include/exclude tags from params --- app/views/search/_filters.html.erb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 970784b6e..5b404f40f 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -38,12 +38,14 @@
<%= label_tag :include_tags, 'Include Tags', class: "form-element" %> - <%= select_tag :include_tags, options_for_select([]), multiple: true, class: 'form-element js-tag-select', + <%= select_tag :include_tags, options_for_select(Tag.where(id: params[:include_tags]).map { |tag| [tag.name, tag.id] }, + selected: params[:include_tags] || []), multiple: true, class: 'form-element js-tag-select', data: { tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %>
<%= label_tag :exclude_tags, 'Exclude Tags', class: "form-element" %> - <%= select_tag :exclude_tags, options_for_select([]), multiple: true, class: 'form-element js-tag-select', + <%= select_tag :exclude_tags, options_for_select(Tag.where(id: params[:exclude_tags]).map { |tag| [tag.name, tag.id] }, + selected: params[:exclude_tags]|| []), multiple: true, class: 'form-element js-tag-select', data: { tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %>
From 3e6d19b7a3b082c7d900fdb480148b439ed82dc9 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Fri, 16 Sep 2022 10:41:53 +0200 Subject: [PATCH 064/968] Overhaul tag search It was quite challenging to find a way to grab the correct tags, get only distinct tags and sort appropriately in a rails scope on which additional joins/wheres/group bys can be applied, but this seems to do the job. --- app/models/tag.rb | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/models/tag.rb b/app/models/tag.rb index 14227535b..6fd3aff37 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -19,16 +19,19 @@ class Tag < ApplicationRecord validates :name, uniqueness: { scope: [:tag_set_id], case_sensitive: false } def self.search(term) - base = joins(:tag_synonyms).includes(:tag_synonyms) - base.where('tags.name LIKE ?', "%#{sanitize_sql_like(term)}%") - .or(base.where('excerpt LIKE ?', "%#{sanitize_sql_like(term)}%")) - .or(base.where('tag_synonyms.name LIKE ?', "%#{sanitize_sql_like(term)}%")) - .distinct - .order(Arel.sql(sanitize_sql_array([ - 'tags.name LIKE ? DESC, tag_synonyms.name LIKE ? DESC, tags.name', - "#{sanitize_sql_like(term)}%", - "#{sanitize_sql_like(term)}%" - ]))) + # Query to search on tags, the name is used for sorting. + q1 = where('tags.name LIKE ?', "%#{sanitize_sql_like(term)}%") + .or(where('tags.excerpt LIKE ?', "%#{sanitize_sql_like(term)}%")) + .select(Arel.sql('name AS sortname, tags.*')) + + # Query to search on synonyms, the synonym name is used for sorting. + # The order clause here actually applies to the union of q1 and q2 (so not just q2). + q2 = joins(:tag_synonyms) + .where('tag_synonyms.name LIKE ?', "%#{sanitize_sql_like(term)}%") + .select(Arel.sql('tag_synonyms.name AS sortname, tags.*')) + .order(Arel.sql(sanitize_sql_array(['sortname LIKE ? DESC, sortname', "#{sanitize_sql_like(term)}%"]))) + + from(Arel.sql("(#{q1.to_sql} UNION #{q2.to_sql}) tags")) end def all_children From ea9973c5460d12dca7460ea9192056433092e394 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Fri, 16 Sep 2022 11:11:14 +0200 Subject: [PATCH 065/968] Ensure no spaces in synonym names --- app/models/tag_synonym.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/tag_synonym.rb b/app/models/tag_synonym.rb index d223ecb20..da0aec259 100644 --- a/app/models/tag_synonym.rb +++ b/app/models/tag_synonym.rb @@ -2,6 +2,7 @@ class TagSynonym < ApplicationRecord belongs_to :tag has_one :community, through: :tag + validates :name, presence: true, format: { with: /[^ \t]+/, message: 'Tag names may not include spaces' } validate :name_unique private From b06ad908681aad41fd7d119e0388b3fc051ee6dd Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Fri, 16 Sep 2022 11:31:47 +0200 Subject: [PATCH 066/968] Show synonyms on overview Make synonyms teal --- app/views/tags/_tag.html.erb | 7 +++++++ app/views/tags/show.html.erb | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/views/tags/_tag.html.erb b/app/views/tags/_tag.html.erb index ccb2d5644..c0beafbb0 100644 --- a/app/views/tags/_tag.html.erb +++ b/app/views/tags/_tag.html.erb @@ -8,6 +8,13 @@ <% end %> <%= link_to tag.name, tag_path(id: category.id, tag_id: tag.id), class: classes, 'data-ckb-item-link' => '' %> × <%= tag.post_count %> + <% if tag.tag_synonyms.present? %> +

+ <% tag.tag_synonyms.each do |synonym| %> + <%= synonym.name %> + <% end %> +

+ <% end %> <% if tag.excerpt.present? %>

<% splat = split_words_max_length(tag.excerpt, 120) %> diff --git a/app/views/tags/show.html.erb b/app/views/tags/show.html.erb index 0aba8b951..6e2b8b57e 100644 --- a/app/views/tags/show.html.erb +++ b/app/views/tags/show.html.erb @@ -21,7 +21,7 @@ <% if @tag.tag_synonyms.any? %> Synonyms: <% @tag.tag_synonyms.each do |synonym| %> - <%= synonym.name %> + <%= synonym.name %> <% end %> <% end %> From 3e13c8f6ce782675a6afbf8291a76bdf08c1f5e0 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Fri, 16 Sep 2022 04:49:30 -0700 Subject: [PATCH 067/968] Fully implement tag filters --- app/assets/javascripts/filters.js | 28 ++++++++++++++++--- app/controllers/users_controller.rb | 7 +++-- app/models/filter.rb | 4 +++ app/views/search/_filters.html.erb | 23 +++++++++------ .../20220916075849_add_tags_to_filters.rb | 6 ++++ db/schema.rb | 4 ++- 6 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 db/migrate/20220916075849_add_tags_to_filters.rb diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 86856b8cd..e793f2cab 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -30,7 +30,18 @@ $(() => { // Not a new filter $deleteButton.prop('disabled', filter.system); - const hasChanges = [...$formFilters].some(el => filter[el.name] ? filter[el.name] != el.value : el.value); + const hasChanges = [...$formFilters].some(el => { + const filterValue = filter[el.dataset.name]; + let elValue = $(el).val(); + console.log(filterValue, elValue) + if (filterValue?.constructor == Array) { + elValue = elValue ?? []; + return filterValue.length != elValue.length || filterValue.some((v, i) => v[1] != elValue[i]); + } + else { + return filterValue ? filterValue != elValue : elValue; + } + }); $saveButton.prop('disabled', filter.system || !hasChanges); } @@ -71,7 +82,16 @@ $(() => { } for (const [name, value] of Object.entries(preset)) { - $form.find(`.form--filter[name=${name}]`).val(value); + const $el = $form.find(`.form--filter[data-name=${name}]`); + if (value?.constructor == Array) { + $el.val(null); + for (const val of value) { + $el.append(new Option(val[0], val[1], false, true)); + } + $el.trigger('change'); + } else { + $el.val(value).trigger('change'); + } } }); computeEnables(); @@ -87,8 +107,8 @@ $(() => { const filter = {}; - for (const el of $('.form--filter')) { - filter[el.name] = el.value; + for (const el of $formFilters) { + filter[el.dataset.name] = $(el).val(); } await QPixel.setFilter($select.val(), filter); diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f3cd772d9..7399417d8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -89,8 +89,11 @@ def filters def set_filter if params[:name] filter = Filter.find_or_create_by(user: current_user, name: params[:name]) - filter.update(min_score: params[:min_score], max_score: params[:max_score], min_answers: params[:min_answers], - max_answers: params[:max_answers], status: params[:status]) + + filter.update(min_score: params[:min_score], max_score: params[:max_score], + min_answers: params[:min_answers], max_answers: params[:max_answers], + include_tags: params[:include_tags], exclude_tags: params[:exclude_tags], + status: params[:status]) render json: { status: 'success', success: true, filters: filters_json } else diff --git a/app/models/filter.rb b/app/models/filter.rb index 9240c80e5..79be0b5ba 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -1,5 +1,7 @@ class Filter < ApplicationRecord belongs_to :user + serialize :include_tags, Array + serialize :exclude_tags, Array # Helper method to convert it to the form expected by the client def json @@ -8,6 +10,8 @@ def json max_score: max_score, min_answers: min_answers, max_answers: max_answers, + include_tags: Tag.where(id: include_tags).map { |tag| [tag.name, tag.id] }, + exclude_tags: Tag.where(id: exclude_tags).map { |tag| [tag.name, tag.id] }, status: status, system: user_id == -1 } diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 5b404f40f..df3bdbeff 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -16,42 +16,47 @@

<%= label_tag :min_score, 'Min Score', class: "form-element" %> <%= number_field_tag :min_score, params[:min_score], - min: 0, max: 1, step: 0.01, class: 'form-element form--filter' %> + min: 0, max: 1, step: 0.01, class: 'form-element form--filter', + data: { name: 'min_score' } %>
<%= label_tag :max_score, 'Max Score', class: "form-element" %> <%= number_field_tag :max_score, params[:max_score], - min: 0, max: 1, step: 0.01, class: 'form-element form--filter' %> + min: 0, max: 1, step: 0.01, class: 'form-element form--filter', + data: { name: 'max_score' } %>
<%= label_tag :min_answers, 'Min Answers', class: "form-element" %> <%= number_field_tag :min_answers, params[:min_answers], - min: 0, step: 1, class: 'form-element form--filter' %> + min: 0, step: 1, class: 'form-element form--filter', + data: { name: 'min_answers' } %>
<%= label_tag :max_answers, 'Max Answers', class: "form-element" %> <%= number_field_tag :max_answers, params[:max_answers], - min: 0, step: 1, class: 'form-element form--filter' %> + min: 0, step: 1, class: 'form-element form--filter', + data: { name: 'max_answers' } %>
<%= label_tag :include_tags, 'Include Tags', class: "form-element" %> <%= select_tag :include_tags, options_for_select(Tag.where(id: params[:include_tags]).map { |tag| [tag.name, tag.id] }, - selected: params[:include_tags] || []), multiple: true, class: 'form-element js-tag-select', - data: { tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %> + selected: params[:include_tags] || []), multiple: true, class: 'form-element form--filter js-tag-select', + data: { name: 'include_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %>
<%= label_tag :exclude_tags, 'Exclude Tags', class: "form-element" %> <%= select_tag :exclude_tags, options_for_select(Tag.where(id: params[:exclude_tags]).map { |tag| [tag.name, tag.id] }, - selected: params[:exclude_tags]|| []), multiple: true, class: 'form-element js-tag-select', - data: { tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %> + selected: params[:exclude_tags]|| []), multiple: true, class: 'form-element form--filter js-tag-select', + data: { name: 'exclude_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %>
<%= label_tag :status, 'Status', class: "form-element" %> <%= select_tag :status, options_for_select(['any', 'open', 'closed'], selected: params[:status] || 'any'), - min: 0, step: 1, class: 'form-element form--filter' %> + min: 0, step: 1, class: 'form-element form--filter', + data: { name: 'status' } %>
<% if allow_apply %> diff --git a/db/migrate/20220916075849_add_tags_to_filters.rb b/db/migrate/20220916075849_add_tags_to_filters.rb new file mode 100644 index 000000000..08eb02fc3 --- /dev/null +++ b/db/migrate/20220916075849_add_tags_to_filters.rb @@ -0,0 +1,6 @@ +class AddTagsToFilters < ActiveRecord::Migration[7.0] + def change + add_column :filters, :include_tags, :string + add_column :filters, :exclude_tags, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 03093d545..39303de94 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_09_14_193044) do +ActiveRecord::Schema[7.0].define(version: 2022_09_16_075849) do create_table "abilities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "community_id" t.string "name" @@ -244,6 +244,8 @@ t.string "status" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "include_tags" + t.string "exclude_tags" t.index ["user_id"], name: "index_filters_on_user_id" end From bfbf4dae79eb07736ade24a9f633fb0c5321e1ee Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Fri, 16 Sep 2022 05:15:13 -0700 Subject: [PATCH 068/968] Bug fixes --- app/assets/javascripts/filters.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index e793f2cab..618a49ca5 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -33,7 +33,6 @@ $(() => { const hasChanges = [...$formFilters].some(el => { const filterValue = filter[el.dataset.name]; let elValue = $(el).val(); - console.log(filterValue, elValue) if (filterValue?.constructor == Array) { elValue = elValue ?? []; return filterValue.length != elValue.length || filterValue.some((v, i) => v[1] != elValue[i]); @@ -63,7 +62,7 @@ $(() => { } // Clear out any old options - $select.children().filter((_, option) => !filters[option.value]).detach(); + $select.children().filter((_, option) => option.value && !filters[option.value]).detach(); $select.select2({ data: Object.keys(filters), tags: true, @@ -116,18 +115,21 @@ $(() => { await initializeSelect(); }); + function clear() { + $select.val(null).trigger('change'); + $form.find('.form--filter').val(null).trigger('change'); + computeEnables(); + } + $deleteButton?.on('click', async evt => { if (confirm(`Are you sure you want to delete ${$select.val()}?`)) { await QPixel.deleteFilter($select.val()); // Reinitialize to get new options await initializeSelect(); + clear(); } }); - $form.find('.filter-clear').on('click', _ => { - $select.val(null).trigger('change'); - $form.find('.form--filter').val(null).trigger('change'); - computeEnables(); - }); + $form.find('.filter-clear').on('click', clear); }); }); \ No newline at end of file From 68bb1f447c1aec76f88df3b636a3e9d3a34a3a7e Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Fri, 16 Sep 2022 17:15:15 +0200 Subject: [PATCH 069/968] Add working (but ugly) synonym addition --- app/assets/javascripts/tags.js | 12 ++++++++++++ app/controllers/tags_controller.rb | 1 + app/models/tag.rb | 2 +- app/views/tags/_form.html.erb | 12 +++++++++--- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index 60700dd23..d969ddaa4 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -79,6 +79,18 @@ $(() => { } } + $('#add-tag-synonym').on('click', ev => { + const $wrapper = $('#tag-synonyms-wrapper'); + const lastId = $wrapper.children().last().attr('id'); + const newId = parseInt(lastId, 10) + 1; + const newFieldset = $wrapper.find('[id="-1"]')[0].outerHTML.replace(/-1/g, newId).replace(/disabled/g, '').replace(/hidden/g, ''); + $wrapper.append(newFieldset); + $wrapper.find(`[id="${newId}"] .remove-tag-synonym`).click(function() { + console.log('LOG') + $(this).parent().remove(); + }); + }); + $('.js-add-required-tag').on('click', ev => { const $tgt = $(ev.target); const useIds = $tgt.attr('data-use-ids') === 'true'; diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 53f09b5e1..2668315b2 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -61,6 +61,7 @@ def show def new @tag = Tag.new + @tag.tag_synonyms.build end def create diff --git a/app/models/tag.rb b/app/models/tag.rb index 6fd3aff37..3f30ad6d1 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -6,7 +6,7 @@ class Tag < ApplicationRecord has_many :children_with_paths, class_name: 'TagWithPath', foreign_key: :parent_id has_many :post_history_tags has_many :tag_synonyms, dependent: :destroy - accepts_nested_attributes_for :tag_synonyms + accepts_nested_attributes_for :tag_synonyms, allow_destroy: true belongs_to :tag_set belongs_to :parent, class_name: 'Tag', optional: true diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb index c2d08ff66..92bb413cf 100644 --- a/app/views/tags/_form.html.erb +++ b/app/views/tags/_form.html.erb @@ -25,12 +25,18 @@
<%= f.label :tag_synonyms, 'Tag Synonyms', class: 'form-element' %> + + Alternative names for this tag + <%= f.fields_for :tag_synonyms do |tsf| %> -
- <%= tsf.label :name, 'Name', class: 'form-element' %> - <%= tsf.text_field :name, class: 'form-element' %> +
+
<% end %> +
From 8257ec742472c54e31711b19891ae229243b446a Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Fri, 16 Sep 2022 20:47:22 +0200 Subject: [PATCH 070/968] Fix synonyms display when option selected --- app/assets/javascripts/tags.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index d969ddaa4..3595a67d0 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -14,7 +14,8 @@ $(() => { }; const template = (tag) => { - const tagSpan = `${tag.text}`; + const tagSynonyms = !!tag.synonyms ? ` (${tag.synonyms})` : ''; + const tagSpan = `${tag.text}${tagSynonyms}`; let desc = !!tag.desc ? splitWordsMaxLength(tag.desc, 120) : ''; const descSpan = !!tag.desc ? `
${desc[0]}${desc.length > 1 ? '...' : ''}` : @@ -60,7 +61,8 @@ $(() => { return { results: data.map(t => ({ id: useIds ? t.id : t.name, - text: t.name.replace(//g, '>') + convert_synonyms(t.tag_synonyms), + text: t.name.replace(//g, '>'), + synonyms: t.tag_synonyms.map((ts) => `${ts.name.replace(//g, '>')}`).join(', '), desc: t.excerpt })) }; @@ -71,14 +73,6 @@ $(() => { }); }); - function convert_synonyms(synonyms) { - if (synonyms.length === 0) { - return ''; - } else { - return ' (' + synonyms.map((ts) => `${ts.name.replace(//g, '>')}`).join(', ') + ')'; - } - } - $('#add-tag-synonym').on('click', ev => { const $wrapper = $('#tag-synonyms-wrapper'); const lastId = $wrapper.children().last().attr('id'); From 706a47d61c5f2434c129cb210a17906ab5d411ba Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Fri, 16 Sep 2022 21:08:23 +0200 Subject: [PATCH 071/968] Add ability to add and modify synonyms --- app/assets/javascripts/tags.js | 32 +++++++++++++++++++++++------- app/controllers/tags_controller.rb | 2 +- app/views/tags/_form.html.erb | 24 +++++++++++++--------- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index 3595a67d0..ab5b4eb62 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -75,16 +75,34 @@ $(() => { $('#add-tag-synonym').on('click', ev => { const $wrapper = $('#tag-synonyms-wrapper'); - const lastId = $wrapper.children().last().attr('id'); + const lastId = $wrapper.children('.tag-synonym').last().attr('data-id'); const newId = parseInt(lastId, 10) + 1; - const newFieldset = $wrapper.find('[id="-1"]')[0].outerHTML.replace(/-1/g, newId).replace(/disabled/g, '').replace(/hidden/g, ''); - $wrapper.append(newFieldset); - $wrapper.find(`[id="${newId}"] .remove-tag-synonym`).click(function() { - console.log('LOG') - $(this).parent().remove(); - }); + + //Duplicate the first element at the end of the wrapper + const newField = $wrapper.find('.tag-synonym[data-id="0"]')[0] + .outerHTML + .replace(/data-id="0"/g, 'data-id="' + newId + '"') + .replace(/(?attributes(\]\[)|(_))0/g, '$' + newId) + $wrapper.append(newField); + + //Alter the newly added tag synonym + const $newTagSynonym = $wrapper.children().last(); + $newTagSynonym.find('.tag-synonym-name').removeAttr('value').removeAttr('readonly'); + $newTagSynonym.find('.destroy-tag-synonym').attr('value', 'false'); + $newTagSynonym.show(); + + //Add handler for removing an element + $newTagSynonym.find(`.remove-tag-synonym`).click(removeTagSynonym); }); + $('.remove-tag-synonym').click(removeTagSynonym); + + function removeTagSynonym() { + const synonym = $(this).closest('.tag-synonym'); + synonym.find('.destroy-tag-synonym').attr('value', 'true'); + synonym.hide(); + } + $('.js-add-required-tag').on('click', ev => { const $tgt = $(ev.target); const useIds = $tgt.attr('data-use-ids') === 'true'; diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 2668315b2..2682b88a3 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -193,7 +193,7 @@ def set_category end def tag_params - params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name, tag_synonyms_attributes: [:name]) + params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name, tag_synonyms_attributes: [:id, :name, :_destroy]) end def exec(sql_array) diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb index 92bb413cf..ed7670c6b 100644 --- a/app/views/tags/_form.html.erb +++ b/app/views/tags/_form.html.erb @@ -28,16 +28,22 @@ Alternative names for this tag - <%= f.fields_for :tag_synonyms do |tsf| %> -
- -
- <% end %> - +
+ <% i = -1 %> + <%= f.fields_for :tag_synonyms do |tsf| %> +
+
+ <%= tsf.text_field :name, class: 'form-element tag-synonym-name', readonly: tsf.object&.name.present? %> +
+
+ +
+ <%= tsf.hidden_field :_destroy, value: false, class: 'destroy-tag-synonym' %> +
+ <% end %> +
+
<%= f.label :parent_id, 'Parent tag', class: 'form-element' %> From 6f3d7afafe60b1b9b3729bc57fe9b659f34198da Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Sun, 18 Sep 2022 22:14:57 -0700 Subject: [PATCH 072/968] Don't show save button unless signed in --- app/views/search/_filters.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index df3bdbeff..aa7ef6f70 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -62,7 +62,9 @@ <% if allow_apply %> <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> <% end %> - + <% if user_signed_in? %> + + <% end %> <% if allow_delete %> <% end %> From 4656f608925ed3df46b953b5f3ea712aeb630b6e Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Mon, 19 Sep 2022 10:38:53 +0200 Subject: [PATCH 073/968] Display only 3 synonyms at maximum --- app/assets/javascripts/tags.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index ab5b4eb62..68ff32d86 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -62,7 +62,7 @@ $(() => { results: data.map(t => ({ id: useIds ? t.id : t.name, text: t.name.replace(//g, '>'), - synonyms: t.tag_synonyms.map((ts) => `${ts.name.replace(//g, '>')}`).join(', '), + synonyms: processSynonyms($this, t.tag_synonyms), desc: t.excerpt })) }; @@ -73,6 +73,22 @@ $(() => { }); }); + function processSynonyms($search, synonyms) { + if (!synonyms) return synonyms; + + if (synonyms.length > 3) { + const searchValue = $search.data('select2').selection.$search.val().toLowerCase(); + displayedSynonyms = synonyms.filter(ts => ts.name.includes(searchValue)).slice(0, 3); + } else { + displayedSynonyms = synonyms; + } + let synonymsString = displayedSynonyms.map((ts) => `${ts.name.replace(//g, '>')}`).join(', '); + if (synonyms.length > displayedSynonyms.length) { + synonymsString += `, ${synonyms.length - displayedSynonyms.length} more synonyms`; + } + return synonymsString; + } + $('#add-tag-synonym').on('click', ev => { const $wrapper = $('#tag-synonyms-wrapper'); const lastId = $wrapper.children('.tag-synonym').last().attr('data-id'); From 9a318467ea474467268168e1932f555181723856 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Mon, 19 Sep 2022 10:39:04 +0200 Subject: [PATCH 074/968] Ensure returned synonyms are distinct --- app/models/tag.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/models/tag.rb b/app/models/tag.rb index 3f30ad6d1..ecac74137 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -31,7 +31,10 @@ def self.search(term) .select(Arel.sql('tag_synonyms.name AS sortname, tags.*')) .order(Arel.sql(sanitize_sql_array(['sortname LIKE ? DESC, sortname', "#{sanitize_sql_like(term)}%"]))) + # Select from the union of the above queries, select only the tag columns such that we can distinct them from(Arel.sql("(#{q1.to_sql} UNION #{q2.to_sql}) tags")) + .select(Tag.column_names.map { |c| "tags.#{c}" }) + .distinct end def all_children From a270e62e1020c2a0170723849cd42e4ece894846 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 20 Sep 2022 18:49:13 -0700 Subject: [PATCH 075/968] Add ability to set a default filter per category --- app/assets/javascripts/filters.js | 10 ++++- app/assets/javascripts/qpixel_api.js | 14 +++++++ app/controllers/categories_controller.rb | 51 +++++++++++++++++++++++- app/helpers/search_helper.rb | 13 ++++++ app/models/user.rb | 8 +++- app/views/categories/show.html.erb | 6 +++ app/views/search/_filters.html.erb | 24 ++++++----- config/config/preferences.yml | 10 ++++- config/routes.rb | 1 + 9 files changed, 122 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 618a49ca5..ef6e76588 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -4,6 +4,7 @@ $(() => { const $form = $select.closest('form'); const $formFilters = $form.find('.form--filter'); const $saveButton = $form.find('.filter-save'); + const $saveAsDefaultButton = $form.find('.filter-save-default'); const $deleteButton = $form.find('.filter-delete'); // Enables/Disables Save & Delete buttons programatically @@ -101,7 +102,7 @@ $(() => { // Enable saving when the filter is changed $formFilters.on('change', computeEnables); - $saveButton.on('click', async evt => { + async function saveFilter() { if (!$form[0].reportValidity()) { return; } const filter = {}; @@ -113,6 +114,13 @@ $(() => { await QPixel.setFilter($select.val(), filter); // Reinitialize to get new options await initializeSelect(); + } + + $saveButton.on('click', saveFilter); + + $saveAsDefaultButton.on('click', async _ => { + await saveFilter(); + QPixel.setFilterAsDefault($select.val()); }); function clear() { diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index e80cc73a8..2d7113dd8 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -284,6 +284,20 @@ window.QPixel = { return this._filters; }, + // Assumes we're on a category page + setFilterAsDefault: async name => { + const resp = await fetch(location.href + '/filters/default', { + method: 'POST', + credentials: 'include', + headers: { + 'X-CSRF-Token': QPixel.csrfToken(), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name }) + }); + }, + setFilter: async (name, filter) => { const resp = await fetch('/users/me/filters', { method: 'POST', diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 3752ede60..c036742df 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -133,6 +133,20 @@ def post_types end end + def default_filter + filter = Filter.find_by(name: params[:name], user_id: [-1, current_user.id]) + + unless filter.present? + return render json: { status: 'failed', success: false, errors: ['Filter does not exist'] }, + status: 400 + end + + key = "prefs.#{current_user.id}.category.#{RequestContext.community_id}.category.#{@category.id}" + RequestContext.redis.hset(key, 'filter', filter.id) + render json: { status: 'success', success: true }, + status: 200 + end + private def set_category @@ -163,7 +177,42 @@ def set_list_posts @posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types) .includes(:post_type, :tags).list_includes filter_qualifiers = helpers.filters_to_qualifiers - @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts) + + @active_filter = { + default: false, + name: params[:predefined_filter], + min_score: params[:min_score], + max_score: params[:max_score], + min_answers: params[:min_answers], + max_answers: params[:max_answers], + include_tags: params[:include_tags], + exclude_tags: params[:exclude_tags], + status: params[:status] + } + + if filter_qualifiers.blank? && user_signed_in? + default_filter_id = current_user.category_preference(@category.id)['filter'] + default_filter = Filter.find_by(id: default_filter_id) + unless default_filter.nil? + filter_qualifiers = helpers.filter_to_qualifiers default_filter unless default_filter.nil? + @active_filter = { + default: true, + name: default_filter.name, + min_score: default_filter.min_score, + max_score: default_filter.max_score, + min_answers: default_filter.min_answers, + max_answers: default_filter.max_answers, + include_tags: default_filter.include_tags, + exclude_tags: default_filter.exclude_tags, + status: default_filter.status + } + end + end + + unless filter_qualifiers.blank? + @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts) + end + @posts = @posts.paginate(page: params[:page], per_page: 50).order(sort_param) end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 596523af2..311a1cdbb 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -26,6 +26,19 @@ def search_posts end end + # Convert a Filter record into a form parseable by the search function + def filter_to_qualifiers(filter) + qualifiers = [] + qualifiers.append({ param: :score, operator: '>=', value: filter.min_score }) unless filter.min_score.nil? + qualifiers.append({ param: :score, operator: '<=', value: filter.max_score }) unless filter.max_score.nil? + qualifiers.append({ param: :answers, operator: '>=', value: filter.min_answers }) unless filter.min_answers.nil? + qualifiers.append({ param: :answers, operator: '<=', value: filter.max_answers }) unless filter.max_answers.nil? + qualifiers.append({ param: :include_tags, tag_ids: filter.include_tags }) unless filter.include_tags.nil? + qualifiers.append({ param: :exclude_tags, tag_ids: filter.exclude_tags }) unless filter.exclude_tags.nil? + qualifiers.append({ param: :status, value: filter.status }) unless filter.status.nil? + qualifiers + end + def filters_to_qualifiers valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, diff --git a/app/models/user.rb b/app/models/user.rb index 628e3f817..421078a14 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -235,13 +235,19 @@ def preferences global_key = "prefs.#{id}" community_key = "prefs.#{id}.community.#{RequestContext.community_id}" { - global: AppConfig.preferences.reject { |_, v| v['community'] }.transform_values { |v| v['default'] } + global: AppConfig.preferences.select { |_, v| v['global'] }.transform_values { |v| v['default'] } .merge(RequestContext.redis.hgetall(global_key)), community: AppConfig.preferences.select { |_, v| v['community'] }.transform_values { |v| v['default'] } .merge(RequestContext.redis.hgetall(community_key)) } end + def category_preference(category_id) + category_key = "prefs.#{id}.category.#{RequestContext.community_id}.category.#{category_id}" + AppConfig.preferences.select { |_, v| v['category'] }.transform_values { |v| v['default'] } + .merge(RequestContext.redis.hgetall(category_key)) + end + def validate_prefs! global_key = "prefs.#{id}" community_key = "prefs.#{id}.community.#{RequestContext.community_id}" diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb index c5bfcce11..e1b56d759 100644 --- a/app/views/categories/show.html.erb +++ b/app/views/categories/show.html.erb @@ -50,6 +50,12 @@
+<% if @active_filter[:default] == true %> +
+ You are currently filtering by <%= @active_filter[:name] %> because it is set as your default for this category +
+<% end %> +
<% @posts.each do |post| %> <%= render 'posts/type_agnostic', post: post %> diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index aa7ef6f70..8ee4802ee 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -1,12 +1,13 @@ <% allow_delete ||= false %> <% allow_apply = true if allow_apply.nil? %> +<% @active_filter ||= {} %>
<%= label_tag :predefined_filter, 'Predefined Filters', class: "form-element" %> - <%= select_tag :predefined_filter, options_for_select([params[:predefined_filter]], - selected: params[:predefined_filter]), + <%= select_tag :predefined_filter, options_for_select([@active_filter[:name]], + selected: @active_filter[:name]), include_blank: true, class: "form-element js-filter-select", id: nil, data: { placeholder: "" } %>
@@ -15,13 +16,13 @@
<%= label_tag :min_score, 'Min Score', class: "form-element" %> - <%= number_field_tag :min_score, params[:min_score], + <%= number_field_tag :min_score, @active_filter[:min_score], min: 0, max: 1, step: 0.01, class: 'form-element form--filter', data: { name: 'min_score' } %>
<%= label_tag :max_score, 'Max Score', class: "form-element" %> - <%= number_field_tag :max_score, params[:max_score], + <%= number_field_tag :max_score, @active_filter[:max_score], min: 0, max: 1, step: 0.01, class: 'form-element form--filter', data: { name: 'max_score' } %>
@@ -29,32 +30,32 @@
<%= label_tag :min_answers, 'Min Answers', class: "form-element" %> - <%= number_field_tag :min_answers, params[:min_answers], + <%= number_field_tag :min_answers, @active_filter[:min_answers], min: 0, step: 1, class: 'form-element form--filter', data: { name: 'min_answers' } %>
<%= label_tag :max_answers, 'Max Answers', class: "form-element" %> - <%= number_field_tag :max_answers, params[:max_answers], + <%= number_field_tag :max_answers, @active_filter[:max_answers], min: 0, step: 1, class: 'form-element form--filter', data: { name: 'max_answers' } %>
<%= label_tag :include_tags, 'Include Tags', class: "form-element" %> - <%= select_tag :include_tags, options_for_select(Tag.where(id: params[:include_tags]).map { |tag| [tag.name, tag.id] }, - selected: params[:include_tags] || []), multiple: true, class: 'form-element form--filter js-tag-select', + <%= select_tag :include_tags, options_for_select(Tag.where(id: @active_filter[:include_tags]).map { |tag| [tag.name, tag.id] }, + selected: @active_filter[:include_tags] || []), multiple: true, class: 'form-element form--filter js-tag-select', data: { name: 'include_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %>
<%= label_tag :exclude_tags, 'Exclude Tags', class: "form-element" %> - <%= select_tag :exclude_tags, options_for_select(Tag.where(id: params[:exclude_tags]).map { |tag| [tag.name, tag.id] }, - selected: params[:exclude_tags]|| []), multiple: true, class: 'form-element form--filter js-tag-select', + <%= select_tag :exclude_tags, options_for_select(Tag.where(id: @active_filter[:exclude_tags]).map { |tag| [tag.name, tag.id] }, + selected: @active_filter[:exclude_tags]|| []), multiple: true, class: 'form-element form--filter js-tag-select', data: { name: 'exclude_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %>
<%= label_tag :status, 'Status', class: "form-element" %> - <%= select_tag :status, options_for_select(['any', 'open', 'closed'], selected: params[:status] || 'any'), + <%= select_tag :status, options_for_select(['any', 'open', 'closed'], selected: @active_filter[:status] || 'any'), min: 0, step: 1, class: 'form-element form--filter', data: { name: 'status' } %>
@@ -64,6 +65,7 @@ <% end %> <% if user_signed_in? %> + <% end %> <% if allow_delete %> diff --git a/config/config/preferences.yml b/config/config/preferences.yml index 36be0760a..927d7fb56 100644 --- a/config/config/preferences.yml +++ b/config/config/preferences.yml @@ -17,6 +17,7 @@ keyboard_tools: description: > Enable keyboard shortcuts. Press ? for a list of shortcuts. default: 'true' + global: true autosave: type: choice @@ -67,9 +68,16 @@ auto_follow_comment_threads: description: > Automatically follow any comment thread you participate in. default: 'true' + global: true sticky_header: type: boolean description: > Make the top navigation bar sticky. - default: false \ No newline at end of file + default: false + global: true + +default_filter_name: + type: integer + default: none + category: true \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 809a72323..7910b0aea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -239,6 +239,7 @@ root to: 'categories#index', as: :categories get 'new', to: 'categories#new', as: :new_category post 'new', to: 'categories#create', as: :create_category + post ':id/filters/default', to: 'categories#default_filter', as: :set_default_filter get ':id', to: 'categories#show', as: :category get ':id/edit', to: 'categories#edit', as: :edit_category post ':id/edit', to: 'categories#update', as: :update_category From 13127c23bc25fe987dd0c91139c65ef13c117eef Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 22 Sep 2022 11:35:36 +0200 Subject: [PATCH 076/968] Add missing variable declaration --- app/assets/javascripts/tags.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index 68ff32d86..6ec102afa 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -76,6 +76,7 @@ $(() => { function processSynonyms($search, synonyms) { if (!synonyms) return synonyms; + let displayedSynonyms; if (synonyms.length > 3) { const searchValue = $search.data('select2').selection.$search.val().toLowerCase(); displayedSynonyms = synonyms.filter(ts => ts.name.includes(searchValue)).slice(0, 3); From 1f20722a5c3d7c0df5cf9af1683a6f9689495654 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 22 Sep 2022 13:47:22 +0200 Subject: [PATCH 077/968] Fix creation/editing of tag synonyms --- app/assets/javascripts/tags.js | 2 +- app/controllers/tags_controller.rb | 1 + app/views/tags/_form.html.erb | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index 6ec102afa..ce6122b1c 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -104,7 +104,7 @@ $(() => { //Alter the newly added tag synonym const $newTagSynonym = $wrapper.children().last(); - $newTagSynonym.find('.tag-synonym-name').removeAttr('value').removeAttr('readonly'); + $newTagSynonym.find('.tag-synonym-name').removeAttr('value').removeAttr('readonly').removeAttr('disabled'); $newTagSynonym.find('.destroy-tag-synonym').attr('value', 'false'); $newTagSynonym.show(); diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 2682b88a3..74e19f2d7 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -77,6 +77,7 @@ def create def edit check_your_privilege('edit_tags', nil, true) + @tag.tag_synonyms.build end def update diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb index ed7670c6b..f599ca474 100644 --- a/app/views/tags/_form.html.erb +++ b/app/views/tags/_form.html.erb @@ -33,12 +33,12 @@ <%= f.fields_for :tag_synonyms do |tsf| %>
- <%= tsf.text_field :name, class: 'form-element tag-synonym-name', readonly: tsf.object&.name.present? %> + <%= tsf.text_field :name, class: 'form-element tag-synonym-name', readonly: tsf.object&.name.present?, disabled: tsf.object&.name.blank? %>
- <%= tsf.hidden_field :_destroy, value: false, class: 'destroy-tag-synonym' %> + <%= tsf.hidden_field :_destroy, value: tsf.object&.name.blank?, class: 'destroy-tag-synonym' %>
<% end %>
From 5e8d782bf03924913b0b31511aede0a163792df6 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 22 Sep 2022 13:48:58 +0200 Subject: [PATCH 078/968] Fix rubocop issues --- app/controllers/tags_controller.rb | 3 ++- app/models/tag.rb | 10 +++++----- test/models/tag_synonym_test.rb | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 74e19f2d7..3661c38c7 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -194,7 +194,8 @@ def set_category end def tag_params - params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name, tag_synonyms_attributes: [:id, :name, :_destroy]) + params.require(:tag).permit(:excerpt, :wiki_markdown, :parent_id, :name, + tag_synonyms_attributes: [:id, :name, :_destroy]) end def exec(sql_array) diff --git a/app/models/tag.rb b/app/models/tag.rb index ecac74137..98e430147 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -21,15 +21,15 @@ class Tag < ApplicationRecord def self.search(term) # Query to search on tags, the name is used for sorting. q1 = where('tags.name LIKE ?', "%#{sanitize_sql_like(term)}%") - .or(where('tags.excerpt LIKE ?', "%#{sanitize_sql_like(term)}%")) - .select(Arel.sql('name AS sortname, tags.*')) + .or(where('tags.excerpt LIKE ?', "%#{sanitize_sql_like(term)}%")) + .select(Arel.sql('name AS sortname, tags.*')) # Query to search on synonyms, the synonym name is used for sorting. # The order clause here actually applies to the union of q1 and q2 (so not just q2). q2 = joins(:tag_synonyms) - .where('tag_synonyms.name LIKE ?', "%#{sanitize_sql_like(term)}%") - .select(Arel.sql('tag_synonyms.name AS sortname, tags.*')) - .order(Arel.sql(sanitize_sql_array(['sortname LIKE ? DESC, sortname', "#{sanitize_sql_like(term)}%"]))) + .where('tag_synonyms.name LIKE ?', "%#{sanitize_sql_like(term)}%") + .select(Arel.sql('tag_synonyms.name AS sortname, tags.*')) + .order(Arel.sql(sanitize_sql_array(['sortname LIKE ? DESC, sortname', "#{sanitize_sql_like(term)}%"]))) # Select from the union of the above queries, select only the tag columns such that we can distinct them from(Arel.sql("(#{q1.to_sql} UNION #{q2.to_sql}) tags")) diff --git a/test/models/tag_synonym_test.rb b/test/models/tag_synonym_test.rb index ea074d1e6..a5ea423be 100644 --- a/test/models/tag_synonym_test.rb +++ b/test/models/tag_synonym_test.rb @@ -1,4 +1,4 @@ -require "test_helper" +require 'test_helper' class TagSynonymTest < ActiveSupport::TestCase # test "the truth" do From 01bfa86fdae2060af1bf04409908f84aa8d120e5 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 22 Sep 2022 13:54:32 +0200 Subject: [PATCH 079/968] Fix tests --- test/fixtures/tag_synonyms.yml | 12 +++--------- test/fixtures/tags.yml | 5 +++++ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/test/fixtures/tag_synonyms.yml b/test/fixtures/tag_synonyms.yml index d7a332924..48965bc78 100644 --- a/test/fixtures/tag_synonyms.yml +++ b/test/fixtures/tag_synonyms.yml @@ -1,11 +1,5 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -# This model initially had no columns defined. If you add columns to the -# model remove the "{}" from the fixture names and add the columns immediately -# below each fixture, per the syntax in the comments below -# -one: {} -# column: value -# -two: {} -# column: value +base_synonym: + name: synonym + tag: base diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml index de28397ed..0c5a1af59 100644 --- a/test/fixtures/tags.yml +++ b/test/fixtures/tags.yml @@ -38,3 +38,8 @@ child: community: sample tag_set: main parent: topic + +base: + name: base + community: sample + tag_set: main From 6a30bf6d8ff8d52e7d8b9b4262a287217890b3a4 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 22 Sep 2022 15:11:54 +0200 Subject: [PATCH 080/968] Add tests for synonyms --- test/controllers/tags_controller_test.rb | 36 ++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb index 176beff95..4a9c99f4b 100644 --- a/test/controllers/tags_controller_test.rb +++ b/test/controllers/tags_controller_test.rb @@ -12,7 +12,7 @@ class TagsControllerTest < ActionController::TestCase assert_not_nil assigns(:tags) end - test 'index with search params should return tags starting with search' do + test 'index with search params should return tags including search term' do get :index, params: { format: 'json', term: 'dis' } assert_response 200 assert_nothing_raised do @@ -20,7 +20,19 @@ class TagsControllerTest < ActionController::TestCase end assert_not_nil assigns(:tags) JSON.parse(response.body).each do |tag| - assert_equal true, tag['name'].start_with?('dis') + assert_equal true, tag['name'].include?('dis') || tag['tag_synonyms'].any? { |ts| ts['name'].include?('syn') } + end + end + + test 'index with search params should return tags whose synonyms include search term' do + get :index, params: { format: 'json', term: 'syn' } + assert_response 200 + assert_nothing_raised do + JSON.parse(response.body) + end + assert_not_nil assigns(:tags) + JSON.parse(response.body).each do |tag| + assert_equal true, tag['name'].include?('syn') || tag['tag_synonyms'].any? { |ts| ts['name'].include?('syn') } end end @@ -125,6 +137,26 @@ class TagsControllerTest < ActionController::TestCase assert_equal 'things', assigns(:tag).excerpt end + test 'should update tag with synonym addition' do + sign_in users(:deleter) + patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, + tag: { tag_synonyms_attributes: { '1': { name: 'conversation' } } } } + assert_response 302 + assert_redirected_to tag_path(id: categories(:main).id, tag_id: tags(:topic).id) + assert_not_nil assigns(:tag) + assert_equal 'conversation', assigns(:tag).tag_synonyms.first&.name + end + + test 'should update tag with synonym removal' do + sign_in users(:deleter) + patch :update, params: { id: categories(:main).id, tag_id: tags(:base).id, + tag: { tag_synonyms_attributes: { '1': { id: tag_synonyms(:base_synonym).id, _destroy: 'true' } } } } + assert_response 302 + assert_redirected_to tag_path(id: categories(:main).id, tag_id: tags(:base).id) + assert_not_nil assigns(:tag) + assert_equal true, assigns(:tag).tag_synonyms.none? { |ts| ts.name == 'synonym' } + end + test 'should prevent a tag being its own parent' do sign_in users(:deleter) patch :update, params: { id: categories(:main).id, tag_id: tags(:topic).id, From b6fe1ac16e8b38ce20d016dcfa65c8548c3abaab Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 22 Sep 2022 15:13:41 +0200 Subject: [PATCH 081/968] Fix rubocop error --- test/controllers/tags_controller_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb index 4a9c99f4b..cdb3983d9 100644 --- a/test/controllers/tags_controller_test.rb +++ b/test/controllers/tags_controller_test.rb @@ -154,7 +154,7 @@ class TagsControllerTest < ActionController::TestCase assert_response 302 assert_redirected_to tag_path(id: categories(:main).id, tag_id: tags(:base).id) assert_not_nil assigns(:tag) - assert_equal true, assigns(:tag).tag_synonyms.none? { |ts| ts.name == 'synonym' } + assert_equal true, (assigns(:tag).tag_synonyms.none? { |ts| ts.name == 'synonym' }) end test 'should prevent a tag being its own parent' do From 3f3a5b54f04450f1c03f757f3de36ba8af2af2d3 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 28 Sep 2022 15:23:07 -0700 Subject: [PATCH 082/968] Fix fetch path --- app/assets/javascripts/qpixel_api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 2d7113dd8..fd277ae50 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -286,7 +286,7 @@ window.QPixel = { // Assumes we're on a category page setFilterAsDefault: async name => { - const resp = await fetch(location.href + '/filters/default', { + const resp = await fetch(location.pathname + '/filters/default', { method: 'POST', credentials: 'include', headers: { From 981d28ddbb6305c4a119a96384b60711fd11889f Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 28 Sep 2022 15:32:03 -0700 Subject: [PATCH 083/968] Minor refactor of the save-as-default logic --- app/assets/javascripts/filters.js | 3 ++- app/assets/javascripts/qpixel_api.js | 5 ++--- app/views/search/_filters.html.erb | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index ef6e76588..d605c563f 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -120,7 +120,8 @@ $(() => { $saveAsDefaultButton.on('click', async _ => { await saveFilter(); - QPixel.setFilterAsDefault($select.val()); + const id = $saveAsDefaultButton.data('categoryId'); + QPixel.setFilterAsDefault(id, $select.val()); }); function clear() { diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index fd277ae50..59d8667e4 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -284,9 +284,8 @@ window.QPixel = { return this._filters; }, - // Assumes we're on a category page - setFilterAsDefault: async name => { - const resp = await fetch(location.pathname + '/filters/default', { + setFilterAsDefault: async (category_id, name) => { + const resp = await fetch(`/categories/${category_id}/filters/default`, { method: 'POST', credentials: 'include', headers: { diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 8ee4802ee..e9503d941 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -65,7 +65,10 @@ <% end %> <% if user_signed_in? %> - + <% if defined? @category %> + + <% end%> <% end %> <% if allow_delete %> From 13688122a1eff21bda52c07cbc44ce2affaf45b5 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 28 Sep 2022 17:04:27 -0700 Subject: [PATCH 084/968] Move filters to top of category listing --- app/views/categories/show.html.erb | 9 ++++++++- app/views/layouts/_sidebar.html.erb | 12 ------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb index e1b56d759..9a6da82f7 100644 --- a/app/views/categories/show.html.erb +++ b/app/views/categories/show.html.erb @@ -50,6 +50,13 @@
+
+ Filters + <%= form_tag request.original_url, method: :get do %> + <%= render 'search/filters' %> + <% end %> +
+ <% if @active_filter[:default] == true %>
You are currently filtering by <%= @active_filter[:name] %> because it is set as your default for this category @@ -70,4 +77,4 @@ <%= link_to category_feed_path(@category, format: 'rss') do %> Category RSS feed <% end %> -
\ No newline at end of file +
diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 200040548..d03117b6b 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -86,18 +86,6 @@
<% end %> - <% if current_page?(root_path) || defined?(@category) && current_page?(category_path(@category)) %> -
-
- Filters -
-
- <%= form_tag request.original_url, method: :get do %> - <%= render 'search/filters' %> - <% end %> -
-
- <% end %> <% unless @community.is_fake %> <% if user_signed_in? %>
From 712344919a9a126e318fb629635cd60c24d56ead Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 28 Sep 2022 17:44:34 -0700 Subject: [PATCH 085/968] Save vertical space in filter form --- app/views/search/_filters.html.erb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index e9503d941..7f5b61936 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -26,8 +26,6 @@ min: 0, max: 1, step: 0.01, class: 'form-element form--filter', data: { name: 'max_score' } %>
-
-
<%= label_tag :min_answers, 'Min Answers', class: "form-element" %> <%= number_field_tag :min_answers, @active_filter[:min_answers], @@ -41,17 +39,19 @@ data: { name: 'max_answers' } %>
-
- <%= label_tag :include_tags, 'Include Tags', class: "form-element" %> - <%= select_tag :include_tags, options_for_select(Tag.where(id: @active_filter[:include_tags]).map { |tag| [tag.name, tag.id] }, +
+
+ <%= label_tag :include_tags, 'Include Tags', class: "form-element" %> + <%= select_tag :include_tags, options_for_select(Tag.where(id: @active_filter[:include_tags]).map { |tag| [tag.name, tag.id] }, selected: @active_filter[:include_tags] || []), multiple: true, class: 'form-element form--filter js-tag-select', data: { name: 'include_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %> -
-
- <%= label_tag :exclude_tags, 'Exclude Tags', class: "form-element" %> - <%= select_tag :exclude_tags, options_for_select(Tag.where(id: @active_filter[:exclude_tags]).map { |tag| [tag.name, tag.id] }, +
+
+ <%= label_tag :exclude_tags, 'Exclude Tags', class: "form-element" %> + <%= select_tag :exclude_tags, options_for_select(Tag.where(id: @active_filter[:exclude_tags]).map { |tag| [tag.name, tag.id] }, selected: @active_filter[:exclude_tags]|| []), multiple: true, class: 'form-element form--filter js-tag-select', data: { name: 'exclude_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %> +
<%= label_tag :status, 'Status', class: "form-element" %> From ddc63a085db310ee517db2f1ab05dc02edb98d38 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 28 Sep 2022 18:19:52 -0700 Subject: [PATCH 086/968] More space saving --- app/views/search/_filters.html.erb | 40 ++++++++++++++---------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 7f5b61936..2b8fbd9fd 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -12,6 +12,19 @@ data: { placeholder: "" } %>
+ <% if allow_apply %> + <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> + <% end %> + <% if user_signed_in? %> + + <% if defined? @category %> + + <% end%> + <% end %> + <% if allow_delete %> + + <% end %>
@@ -38,6 +51,12 @@ min: 0, step: 1, class: 'form-element form--filter', data: { name: 'max_answers' } %>
+
+ <%= label_tag :status, 'Status', class: "form-element" %> + <%= select_tag :status, options_for_select(['any', 'open', 'closed'], selected: @active_filter[:status] || 'any'), + min: 0, step: 1, class: 'form-element form--filter', + data: { name: 'status' } %> +
@@ -53,25 +72,4 @@ data: { name: 'exclude_tags', tag_set: @category&.tag_set_id, create: 'false', use_ids: 'true', placeholder: '' } %>
-
- <%= label_tag :status, 'Status', class: "form-element" %> - <%= select_tag :status, options_for_select(['any', 'open', 'closed'], selected: @active_filter[:status] || 'any'), - min: 0, step: 1, class: 'form-element form--filter', - data: { name: 'status' } %> -
-
- <% if allow_apply %> - <%= submit_tag 'Apply', class: 'button is-medium is-outlined', name: nil %> - <% end %> - <% if user_signed_in? %> - - <% if defined? @category %> - - <% end%> - <% end %> - <% if allow_delete %> - - <% end %> -
From d5df4af177209bc28d9d202b65bd212579054601 Mon Sep 17 00:00:00 2001 From: Monica Cellio Date: Thu, 29 Sep 2022 21:34:18 -0400 Subject: [PATCH 087/968] Generalized wording on vote-summary page so it works for both you and other users. --- app/views/users/vote_summary.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/vote_summary.html.erb b/app/views/users/vote_summary.html.erb index e6115fa79..96324e836 100644 --- a/app/views/users/vote_summary.html.erb +++ b/app/views/users/vote_summary.html.erb @@ -2,7 +2,7 @@

Vote summary for <%= user_link @user %>

-

A daily summary of votes you have received for your posts.

+

A daily summary of votes received for posts.

<% @votes.each do |day, vote_list| %>
> From 20bf97052da93c1a923c21b4f8325978dda86a85 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Fri, 30 Sep 2022 00:43:39 -0700 Subject: [PATCH 088/968] Move code for generating JSON for filters --- app/controllers/users_controller.rb | 18 ++++++++++++++++-- app/models/filter.rb | 14 -------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7399417d8..bcdf16f78 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -64,13 +64,27 @@ def preferences end end + # Helper method to convert it to the form expected by the client + def filter_json(filter) + { + min_score: filter.min_score, + max_score: filter.max_score, + min_answers: filter.min_answers, + max_answers: filter.max_answers, + include_tags: Tag.where(id: filter.include_tags).map { |tag| [tag.name, tag.id] }, + exclude_tags: Tag.where(id: filter.exclude_tags).map { |tag| [tag.name, tag.id] }, + status: filter.status, + system: filter.user_id == -1 + } + end + def filters_json system_filters = Rails.cache.fetch 'system_filters' do - User.find(-1).filters.to_h { |filter| [filter.name, filter.json] } + User.find(-1).filters.to_h { |filter| [filter.name, filter_json(filter)] } end if user_signed_in? - current_user.filters.to_h { |filter| [filter.name, filter.json] } + current_user.filters.to_h { |filter| [filter.name, filter_json(filter)] } .merge(system_filters) else system_filters diff --git a/app/models/filter.rb b/app/models/filter.rb index 79be0b5ba..6579be7de 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -2,18 +2,4 @@ class Filter < ApplicationRecord belongs_to :user serialize :include_tags, Array serialize :exclude_tags, Array - - # Helper method to convert it to the form expected by the client - def json - { - min_score: min_score, - max_score: max_score, - min_answers: min_answers, - max_answers: max_answers, - include_tags: Tag.where(id: include_tags).map { |tag| [tag.name, tag.id] }, - exclude_tags: Tag.where(id: exclude_tags).map { |tag| [tag.name, tag.id] }, - status: status, - system: user_id == -1 - } - end end From 47a0456e185c7dfb017c91739d74052992ed0921 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Sun, 2 Oct 2022 16:52:46 -0700 Subject: [PATCH 089/968] Save default filters in database instead of cache --- app/assets/javascripts/filters.js | 24 ++++++++++--------- app/assets/javascripts/qpixel_api.js | 15 ++++++++++-- app/controllers/categories_controller.rb | 18 ++------------ app/controllers/users_controller.rb | 20 ++++++++++++++-- app/helpers/users_helper.rb | 14 +++++++++++ app/models/category_filter_default.rb | 5 ++++ app/views/search/_filters.html.erb | 8 +++---- config/routes.rb | 1 + ...2043021_create_category_filter_defaults.rb | 9 +++++++ db/schema.rb | 14 ++++++++++- test/fixtures/category_filter_defaults.yml | 11 +++++++++ test/models/category_filter_default_test.rb | 7 ++++++ 12 files changed, 110 insertions(+), 36 deletions(-) create mode 100644 app/models/category_filter_default.rb create mode 100644 db/migrate/20221002043021_create_category_filter_defaults.rb create mode 100644 test/fixtures/category_filter_defaults.yml create mode 100644 test/models/category_filter_default_test.rb diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index d605c563f..bc20e407f 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -1,10 +1,12 @@ $(() => { - $('.js-filter-select').each((_, el) => { + $('.js-filter-select').each(async (_, el) => { const $select = $(el); const $form = $select.closest('form'); const $formFilters = $form.find('.form--filter'); const $saveButton = $form.find('.filter-save'); - const $saveAsDefaultButton = $form.find('.filter-save-default'); + const $isDefaultCheckbox = $form.find('.filter-is-default'); + const categoryId = $isDefaultCheckbox.val(); + var defaultFilter = await QPixel.defaultFilter(categoryId); const $deleteButton = $form.find('.filter-delete'); // Enables/Disables Save & Delete buttons programatically @@ -42,10 +44,12 @@ $(() => { return filterValue ? filterValue != elValue : elValue; } }); - $saveButton.prop('disabled', filter.system || !hasChanges); + const defaultStatusChanged = $isDefaultCheckbox.prop('checked') != (defaultFilter === $select.val()); + $saveButton.prop('disabled', !defaultStatusChanged && (filter.system || !hasChanges)); } async function initializeSelect() { + defaultFilter = await QPixel.defaultFilter(categoryId); const filters = await QPixel.filters(); function template(option) { @@ -70,10 +74,11 @@ $(() => { templateResult: template, templateSelection: template - }).on('select2:select', evt => { + }).on('select2:select', async evt => { const filterName = evt.params.data.id; const preset = filters[filterName]; + $isDefaultCheckbox.prop('checked', defaultFilter === $select.val()); computeEnables(); // Name is not one of the presets, i.e user is creating a new preset @@ -101,6 +106,7 @@ $(() => { // Enable saving when the filter is changed $formFilters.on('change', computeEnables); + $isDefaultCheckbox.on('change', computeEnables); async function saveFilter() { if (!$form[0].reportValidity()) { return; } @@ -111,19 +117,15 @@ $(() => { filter[el.dataset.name] = $(el).val(); } - await QPixel.setFilter($select.val(), filter); + await QPixel.setFilter($select.val(), filter, categoryId, $isDefaultCheckbox.prop('checked')); + defaultFilter = await QPixel.defaultFilter(categoryId); + // Reinitialize to get new options await initializeSelect(); } $saveButton.on('click', saveFilter); - $saveAsDefaultButton.on('click', async _ => { - await saveFilter(); - const id = $saveAsDefaultButton.data('categoryId'); - QPixel.setFilterAsDefault(id, $select.val()); - }); - function clear() { $select.val(null).trigger('change'); $form.find('.form--filter').val(null).trigger('change'); diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 59d8667e4..ebc40db07 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -284,6 +284,17 @@ window.QPixel = { return this._filters; }, + defaultFilter: async categoryId => { + const resp = await fetch(`/users/me/filters/default?category=${categoryId}`, { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + const data = await resp.json(); + return data.name; + }, + setFilterAsDefault: async (category_id, name) => { const resp = await fetch(`/categories/${category_id}/filters/default`, { method: 'POST', @@ -297,7 +308,7 @@ window.QPixel = { }); }, - setFilter: async (name, filter) => { + setFilter: async (name, filter, category, is_default) => { const resp = await fetch('/users/me/filters', { method: 'POST', credentials: 'include', @@ -306,7 +317,7 @@ window.QPixel = { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify(Object.assign(filter, { name })) + body: JSON.stringify(Object.assign(filter, { name, category, is_default })) }); const data = await resp.json(); if (data.status !== 'success') { diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index c036742df..9069e9d15 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -133,20 +133,6 @@ def post_types end end - def default_filter - filter = Filter.find_by(name: params[:name], user_id: [-1, current_user.id]) - - unless filter.present? - return render json: { status: 'failed', success: false, errors: ['Filter does not exist'] }, - status: 400 - end - - key = "prefs.#{current_user.id}.category.#{RequestContext.community_id}.category.#{@category.id}" - RequestContext.redis.hset(key, 'filter', filter.id) - render json: { status: 'success', success: true }, - status: 200 - end - private def set_category @@ -191,10 +177,10 @@ def set_list_posts } if filter_qualifiers.blank? && user_signed_in? - default_filter_id = current_user.category_preference(@category.id)['filter'] + default_filter_id = helpers.default_filter(current_user.id, @category.id) default_filter = Filter.find_by(id: default_filter_id) unless default_filter.nil? - filter_qualifiers = helpers.filter_to_qualifiers default_filter unless default_filter.nil? + filter_qualifiers = helpers.filter_to_qualifiers default_filter @active_filter = { default: true, name: default_filter.name, diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index bcdf16f78..9082c578a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -101,7 +101,7 @@ def filters end def set_filter - if params[:name] + if user_signed_in? && params[:name] filter = Filter.find_or_create_by(user: current_user, name: params[:name]) filter.update(min_score: params[:min_score], max_score: params[:max_score], @@ -109,7 +109,12 @@ def set_filter include_tags: params[:include_tags], exclude_tags: params[:exclude_tags], status: params[:status]) - render json: { status: 'success', success: true, filters: filters_json } + unless params[:category].nil? || params[:is_default].nil? + helpers.set_filter_default(current_user.id, filter.id, params[:category].to_i, params[:is_default]) + end + + render json: { status: 'success', success: true, filters: filters_json }, + status: 200 else render json: { status: 'failed', success: false, errors: ['Filter name is required'] }, status: 400 @@ -142,6 +147,17 @@ def delete_filter end end + def default_filter + if user_signed_in? && params[:category] + default_filter = helpers.default_filter(current_user.id, params[:category].to_i) + render json: { status: 'success', success: true, name: default_filter&.name }, + status: 200 + else + render json: { status: 'failed', success: false }, + status: 400 + end + end + def set_preference if !params[:name].nil? && !params[:value].nil? global_key = "prefs.#{current_user.id}" diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 2ebc8c27c..b0fb61d96 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -30,6 +30,20 @@ def preference_choice(pref_config) end end + def default_filter(user_id, category_id) + CategoryFilterDefault.find_by(user_id: user_id, category_id: category_id)&.filter + end + + def set_filter_default(user_id, filter_id, category_id, keep) + if keep + CategoryFilterDefault.find_or_create_by(user_id: user_id, category_id: category_id) + .update(filter_id: filter_id) + else + CategoryFilterDefault.where(user_id: user_id, category_id: category_id) + .destroy_all + end + end + def user_preference(name, community: false) return nil if current_user.nil? diff --git a/app/models/category_filter_default.rb b/app/models/category_filter_default.rb new file mode 100644 index 000000000..540f1cf78 --- /dev/null +++ b/app/models/category_filter_default.rb @@ -0,0 +1,5 @@ +class CategoryFilterDefault < ApplicationRecord + belongs_to :user + belongs_to :filter + belongs_to :category +end diff --git a/app/views/search/_filters.html.erb b/app/views/search/_filters.html.erb index 2b8fbd9fd..954804e80 100644 --- a/app/views/search/_filters.html.erb +++ b/app/views/search/_filters.html.erb @@ -17,15 +17,15 @@ <% end %> <% if user_signed_in? %> - <% if defined? @category %> - - <% end%> <% end %> <% if allow_delete %> <% end %> + <% if user_signed_in? && defined? @category %> + <%= label_tag :save_as_default, 'Is default for this category?' %> + <%= check_box_tag :save_as_default, @category.id, false, { class: 'filter-is-default form-checkbox-element' } %> + <% end %>
<%= label_tag :min_score, 'Min Score', class: "form-element" %> diff --git a/config/routes.rb b/config/routes.rb index 7910b0aea..b98fdc464 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -171,6 +171,7 @@ get '/me/preferences', to: 'users#preferences', as: :user_preferences post '/me/preferences', to: 'users#set_preference', as: :set_user_preference get '/me/filters', to: 'users#filters', as: :user_filters + get '/me/filters/default/', to: 'users#default_filter', as: :default_filter post '/me/filters', to: 'users#set_filter', as: :set_user_filter delete '/me/filters', to: 'users#delete_filter', as: :delete_user_filter get '/me/notifications', to: 'notifications#index', as: :notifications diff --git a/db/migrate/20221002043021_create_category_filter_defaults.rb b/db/migrate/20221002043021_create_category_filter_defaults.rb new file mode 100644 index 000000000..81e5dfa17 --- /dev/null +++ b/db/migrate/20221002043021_create_category_filter_defaults.rb @@ -0,0 +1,9 @@ +class CreateCategoryFilterDefaults < ActiveRecord::Migration[7.0] + def change + create_table :category_filter_defaults do |t| + t.references :user, null: false, foreign_key: true + t.references :filter, null: false, foreign_key: true + t.references :category, null: false, foreign_key: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 39303de94..0effcfd25 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_09_16_075849) do +ActiveRecord::Schema[7.0].define(version: 2022_10_02_043021) do create_table "abilities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "community_id" t.string "name" @@ -138,6 +138,15 @@ t.bigint "tag_id" end + create_table "category_filter_defaults", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "filter_id", null: false + t.bigint "category_id", null: false + t.index ["category_id"], name: "index_category_filter_defaults_on_category_id" + t.index ["filter_id"], name: "index_category_filter_defaults_on_filter_id" + t.index ["user_id"], name: "index_category_filter_defaults_on_user_id" + end + create_table "close_reasons", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "name" t.text "description", size: :medium @@ -740,6 +749,9 @@ add_foreign_key "audit_logs", "users" add_foreign_key "categories", "licenses" add_foreign_key "categories", "tag_sets" + add_foreign_key "category_filter_defaults", "categories" + add_foreign_key "category_filter_defaults", "filters" + add_foreign_key "category_filter_defaults", "users" add_foreign_key "comment_threads", "users", column: "archived_by_id" add_foreign_key "comment_threads", "users", column: "deleted_by_id" add_foreign_key "comment_threads", "users", column: "locked_by_id" diff --git a/test/fixtures/category_filter_defaults.yml b/test/fixtures/category_filter_defaults.yml new file mode 100644 index 000000000..f1a454af5 --- /dev/null +++ b/test/fixtures/category_filter_defaults.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + filter: one + category: one + +two: + user: two + filter: two + category: two diff --git a/test/models/category_filter_default_test.rb b/test/models/category_filter_default_test.rb new file mode 100644 index 000000000..446d71ac3 --- /dev/null +++ b/test/models/category_filter_default_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class CategoryFilterDefaultTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From 9cf30c312bd9e095b1c842b4269ba094acbe2d99 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Sat, 8 Oct 2022 22:15:06 -0700 Subject: [PATCH 090/968] Don't rerender the preview if nothing changed --- app/assets/javascripts/posts.js | 75 ++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/app/assets/javascripts/posts.js b/app/assets/javascripts/posts.js index 2bdc896ad..3f2ab6b51 100644 --- a/app/assets/javascripts/posts.js +++ b/app/assets/javascripts/posts.js @@ -125,44 +125,49 @@ $(() => { } }); - postFields.on('focus keyup paste change markdown', evt => { - const $tgt = $(evt.target); - - if (!window.converter) { - window.converter = window.markdownit({ - html: true, - breaks: false, - linkify: true - }); - window.converter.use(window.markdownitFootnote); - window.converter.use(window.latexEscape); - } - window.setTimeout(() => { - const converter = window.converter; + postFields.on('focus keyup paste change markdown', (() => { + let previous = null; + return evt => { + const $tgt = $(evt.target); const text = $(evt.target).val(); - const unsafe_html = converter.render(text); - const html = DOMPurify.sanitize(unsafe_html, { - USE_PROFILES: { html: true }, - ALLOWED_TAGS, - ALLOWED_ATTR - }); - $tgt.parents('.form-group').siblings('.post-preview').html(html); - $tgt.parents('form').find('.js-post-html[name="__html"]').val(html + ''); - }, 0); - - if (featureTimeout) { - clearTimeout(featureTimeout); - } - - featureTimeout = setTimeout(() => { - if (window['MathJax']) { - MathJax.typeset(); + // Don't bother re-rendering if nothing's changed + if (text === previous) { return; } + previous = text; + if (!window.converter) { + window.converter = window.markdownit({ + html: true, + breaks: false, + linkify: true + }); + window.converter.use(window.markdownitFootnote); + window.converter.use(window.latexEscape); } - if (window['hljs']) { - hljs.highlightAll(); + window.setTimeout(() => { + const converter = window.converter; + const unsafe_html = converter.render(text); + const html = DOMPurify.sanitize(unsafe_html, { + USE_PROFILES: { html: true }, + ALLOWED_TAGS, + ALLOWED_ATTR + }); + $tgt.parents('.form-group').siblings('.post-preview').html(html); + $tgt.parents('form').find('.js-post-html[name="__html"]').val(html + ''); + }, 0); + + if (featureTimeout) { + clearTimeout(featureTimeout); } - }, 1000); - }).on('keyup', ev => { + + featureTimeout = setTimeout(() => { + if (window['MathJax']) { + MathJax.typeset(); + } + if (window['hljs']) { + hljs.highlightAll(); + } + }, 1000); + }; + })()).on('keyup', ev => { clearTimeout(draftTimeout); const text = $(ev.target).val(); draftTimeout = setTimeout(() => { From 14f63eb4d733ebae9eb3f0c57529713b421e9e6f Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Sun, 9 Oct 2022 19:36:21 -0700 Subject: [PATCH 091/968] Add net votes for each day to the vote summary --- app/controllers/users_controller.rb | 4 +++- app/views/users/vote_summary.html.erb | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 11e95df0d..808d51427 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -462,7 +462,9 @@ def vote_summary .select('count(*) as vote_count') \ .select('date(created_at) as date_of') @votes = @votes.order(date_of: :desc, post_id: :desc).all \ - .group_by(&:date_of).map { |k, vl| [k, vl.group_by(&:post) ] } \ + .group_by(&:date_of).map do |k, vl| + [k, vl.group_by(&:post), vl.sum { |v| v.vote_type * v.vote_count }] + end \ .paginate(page: params[:page], per_page: 15) @votes end diff --git a/app/views/users/vote_summary.html.erb b/app/views/users/vote_summary.html.erb index 96324e836..7fab92d3e 100644 --- a/app/views/users/vote_summary.html.erb +++ b/app/views/users/vote_summary.html.erb @@ -4,10 +4,17 @@

A daily summary of votes received for posts.

-<% @votes.each do |day, vote_list| %> +<% @votes.each do |day, vote_list, net_votes| %>
> -

<%= day.strftime('%b %e, %Y') %>

+

<%= day.strftime('%b %e, %Y') %>: +<% if net_votes > 0 %> + +<%= net_votes %> +<% elsif net_votes < 0 %> + <%= net_votes %> +<% else %> + 0 +<% end %>

<% vote_list.each do |post, vote_data| %> From 13f27bab676c856953a48273b6ac3fbd588aef7a Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Tue, 25 Oct 2022 13:27:46 +0200 Subject: [PATCH 092/968] Add back still required install instructions The `rmagick` library requires imagemagick to also be installed (libvips is the new default used by image processing in rails). --- INSTALLATION.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/INSTALLATION.md b/INSTALLATION.md index 93e42a159..d460b444a 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -56,6 +56,16 @@ If you already have Node.JS installed, you can skip this step. If not, If you haven't already got it, [download and install Redis](https://redis.io/download) or for example `sudo apt install redis-server`. +### Install Imagemagick + +If you haven't already installed Imagemagick, you'll need to +[install it for your system](https://imagemagick.org/script/download.php). + +If you install Imagemagick from APT on a Debian-based system, you may need to +also install the `libmagickwand-dev` package. + +`sudo apt install libmagick++-dev` should also work. + ### Install Libvips If you haven't already installed Libvips, you'll need to [install it for From 913f6c8c6a1fe9846071d17e36b367b8b304d4c7 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 27 Oct 2022 10:53:18 +0200 Subject: [PATCH 093/968] Fix error in docker --- config/environments/development.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/environments/development.rb b/config/environments/development.rb index 3586b8d51..2c58a27a0 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -82,7 +82,7 @@ # Ensure docker ip added to allowed, given that we are in container if File.file?('/.dockerenv') == true host_ip = `/sbin/ip route|awk '/default/ { print $3 }'`.strip - config.web_console.allowed_ips << host_ip + config.web_console.permissions = host_ip # ==> Configuration for :confirmable # A period that the user is allowed to access the website even without From 9792bf84999fd20c83d14e4e704e7c4ca803c436 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 27 Oct 2022 10:53:26 +0200 Subject: [PATCH 094/968] Ensure docker works out of the box --- docker/dummy.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/dummy.env b/docker/dummy.env index 65c42be15..610597621 100644 --- a/docker/dummy.env +++ b/docker/dummy.env @@ -1,5 +1,5 @@ MYSQL_ROOT_PASSWORD=qpixel -MYSQL_DATABASE=qpixel +MYSQL_DATABASE=qpixel_dev MYSQL_USER=qpixel MYSQL_PASSWORD=qpixel COMMUNITY_ADMIN_USERNAME=admin From 20d51ccebdfbcb8261745002d25a94c9374f8f92 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 27 Oct 2022 11:08:04 +0200 Subject: [PATCH 095/968] Add some notes on docker compose and Mac Also update display --- docker/README.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/docker/README.md b/docker/README.md index f7e1a33ba..edfec7c60 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,12 +2,20 @@ A [docker-compose.yml](../docker-compose.yml) file is provided for deployment with Docker compose, if you choose. -To use docker compose, you need to install the docker-compose-plugin. For a system like debian or ubuntu, you can use the following command. +To use docker compose, you need to install the docker-compose-plugin. You can check whether it is already installed by running the following command. + +```bash +sudo docker compose version +``` + +If your version is 2.x or higher, then you are good. Otherwise, you should install the docker compose plugin. For a system like debian or ubuntu, you can use the following command. ```bash sudo apt-get install docker-compose-plugin ``` +For Mac OS, you can install docker desktop by downloading it from the docker website. After starting the application, the docker compose command becomes available in your terminal. + Depending on your setup, you may need to prefix every docker command with sudo. ## 1. Setup and Secrets @@ -49,6 +57,8 @@ docker compose build db docker compose build redis ``` +NOTE: If you get an error like "Cannot connect to the Docker daemon at ...", you need to ensure you start docker. Depending on your system, this can be done with `sudo service docker start` (Ubuntu) or by opening the Docker Desktop application and waiting for it to start (Mac OS). + ## 4. Start Containers Then start your containers! @@ -64,15 +74,17 @@ The uwsgi container has a sleep command for 15 seconds to give the database a ch so don't expect to see output right away. After about 20 seconds, check to make sure the server is running (and verify port 3000, note that you can change this mapping in the `.env` file) ``` -uwsgi_1 | => Booting Puma -uwsgi_1 | => Rails 5.2.4.3 application starting in development -uwsgi_1 | => Run `rails server -h` for more startup options -uwsgi_1 | Puma starting in single mode... -uwsgi_1 | * Version 3.12.6 (ruby 2.6.5-p114), codename: Llamas in Pajamas -uwsgi_1 | * Min threads: 0, max threads: 16 -uwsgi_1 | * Environment: development -uwsgi_1 | * Listening on tcp://localhost:3000 -uwsgi_1 | Use Ctrl-C to stop +qpixel_uwsgi_1 | => Booting Puma +qpixel_uwsgi_1 | => Rails 7.0.4 application starting in development +qpixel_uwsgi_1 | => Run `rails server -h` for more startup options +qpixel_uwsgi_1 | Puma starting in single mode... +qpixel_uwsgi_1 | * Puma version: 5.6.5 (ruby 2.7.6-p219) ("Birdie's Version") +qpixel_uwsgi_1 | * Min threads: 5 +qpixel_uwsgi_1 | * Max threads: 5 +qpixel_uwsgi_1 | * Environment: development +qpixel_uwsgi_1 | * PID: 49 +qpixel_uwsgi_1 | * Listening on http://0.0.0.0:3000 +qpixel_uwsgi_1 | Use Ctrl-C to stop ``` You should then be able to open your browser to [http://localhost:3000](http://localhost:3000) From 1b9ea3472c035f2ce1c62a6b50fe2454a05e029c Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 27 Oct 2022 11:10:27 +0200 Subject: [PATCH 096/968] Also create storage config in docker --- config/storage.docker.yml | 14 ++++++++++++++ docker/local-setup.sh | 1 + 2 files changed, 15 insertions(+) create mode 100644 config/storage.docker.yml diff --git a/config/storage.docker.yml b/config/storage.docker.yml new file mode 100644 index 000000000..88649a3fb --- /dev/null +++ b/config/storage.docker.yml @@ -0,0 +1,14 @@ +test: + service: Disk + root: <%= Rails.root.join('tmp/storage') %> + +local: + service: Disk + root: <%= Rails.root.join('storage') %> + +s3: + service: S3 + access_key_id: "" + secret_access_key: "" + region: us-east-1 + bucket: "" diff --git a/docker/local-setup.sh b/docker/local-setup.sh index 0b2674eeb..ebfbf6e2e 100755 --- a/docker/local-setup.sh +++ b/docker/local-setup.sh @@ -3,3 +3,4 @@ cp ./docker/dummy.env ./docker/env cp ./docker/compose-env .env cp config/database.docker.yml config/database.yml +cp config/storage.docker.yml config/storage.yml From 7778172c650d3a44ea761dfcb3eb1a54271d71d7 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Thu, 27 Oct 2022 15:00:06 +0200 Subject: [PATCH 097/968] Wait until mysql is up properly --- docker-compose.yml | 18 ++++++++++++++---- docker/dummy.env | 2 +- docker/entrypoint.sh | 3 --- docker/mysql-init.sql | 2 -- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 201795be7..61c4c1e7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.8" +version: "3.9" services: db: build: @@ -11,6 +11,13 @@ services: command: mysqld --default-authentication-plugin=mysql_native_password --skip-mysqlx cap_add: - SYS_NICE + healthcheck: + test: mysqladmin ping -h 127.0.0.1 -u $$MYSQL_USER --password=$$MYSQL_PASSWORD + start_period: 5s + interval: 5s + timeout: 5s + retries: 12 + uwsgi: restart: always @@ -18,7 +25,10 @@ services: context: "." dockerfile: docker/Dockerfile depends_on: - - db + db: + condition: service_healthy + redis: + condition: service_healthy environment: - COMMUNITY_NAME=${COMMUNITY_NAME} - RAILS_ENV=${RAILS_ENV} @@ -39,5 +49,5 @@ services: redis: restart: always image: redis:latest - depends_on: - - db + healthcheck: + test: ["CMD", "redis-cli","ping"] diff --git a/docker/dummy.env b/docker/dummy.env index 610597621..65c42be15 100644 --- a/docker/dummy.env +++ b/docker/dummy.env @@ -1,5 +1,5 @@ MYSQL_ROOT_PASSWORD=qpixel -MYSQL_DATABASE=qpixel_dev +MYSQL_DATABASE=qpixel MYSQL_USER=qpixel MYSQL_PASSWORD=qpixel COMMUNITY_ADMIN_USERNAME=admin diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 145b478c6..efbf2b14e 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,8 +1,5 @@ #!/bin/bash -# Give database chance to finish creation -sleep 15 - # If not created yet if [ ! -f "/db-created" ]; then rails db:create diff --git a/docker/mysql-init.sql b/docker/mysql-init.sql index 2996693ec..510059bb9 100644 --- a/docker/mysql-init.sql +++ b/docker/mysql-init.sql @@ -1,8 +1,6 @@ /* database qpixel and user are already created * if you change your environment file, you need to update database names here */ -CREATE DATABASE qpixel_dev; -CREATE DATABASE qpixel_test; GRANT ALL ON qpixel_dev.* TO qpixel; GRANT ALL ON qpixel_test.* TO qpixel; GRANT ALL ON qpixel.* TO qpixel; From 27e9e62b2307d5014d7221783ad7ed4137bf2b19 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Mon, 31 Oct 2022 13:03:15 +0100 Subject: [PATCH 098/968] Remove outdated config option --- .circleci/config.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d83e7122e..56dfd2403 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,6 @@ jobs: command: [--default-authentication-plugin=mysql_native_password] environment: MYSQL_ROOT_HOST: '%' - MYSQL_USER: 'root' MYSQL_ROOT_PASSWORD: 'root' MYSQL_DATABASE: 'qpixel_test' - image: cimg/redis:7.0 From 3fcaf31e54dd5ff32dc6d876357436ac696cdede Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 2 Nov 2022 16:17:26 -0700 Subject: [PATCH 099/968] Display name of active filter --- app/controllers/categories_controller.rb | 6 ++---- app/views/categories/show.html.erb | 13 ++++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 9069e9d15..dec072762 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -195,10 +195,8 @@ def set_list_posts end end - unless filter_qualifiers.blank? - @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts) - end - + @posts = helpers.qualifiers_to_sql(filter_qualifiers, @posts) + @filtered = filter_qualifiers.any? @posts = @posts.paginate(page: params[:page], per_page: 50).order(sort_param) end diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb index 9a6da82f7..9fb6a52ec 100644 --- a/app/views/categories/show.html.erb +++ b/app/views/categories/show.html.erb @@ -51,18 +51,17 @@
- Filters + Filters (<%= @filtered ? @active_filter[:name] || 'Custom' : 'None' %>) + <% if @active_filter[:default] == true %> +
+ You are currently filtering by <%= @active_filter[:name] %> because it is set as your default for this category +
+ <% end %> <%= form_tag request.original_url, method: :get do %> <%= render 'search/filters' %> <% end %>
-<% if @active_filter[:default] == true %> -
- You are currently filtering by <%= @active_filter[:name] %> because it is set as your default for this category -
-<% end %> -
<% @posts.each do |post| %> <%= render 'posts/type_agnostic', post: post %> From 39eff84985adc7c1a0c522364f6687b9060a4e00 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 2 Nov 2022 16:30:32 -0700 Subject: [PATCH 100/968] Fix checkbox fill and clear --- app/assets/javascripts/filters.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index bc20e407f..1f190d810 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -50,6 +50,7 @@ $(() => { async function initializeSelect() { defaultFilter = await QPixel.defaultFilter(categoryId); + $isDefaultCheckbox.prop('checked', defaultFilter === $select.val()); const filters = await QPixel.filters(); function template(option) { @@ -129,6 +130,7 @@ $(() => { function clear() { $select.val(null).trigger('change'); $form.find('.form--filter').val(null).trigger('change'); + $isDefaultCheckbox.prop('checked', false); computeEnables(); } From a8e07f2307440fe6757f5b25cffcc19db3cbdf84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Nov 2022 15:20:00 +0000 Subject: [PATCH 101/968] Bump nokogiri from 1.13.8 to 1.13.9 Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.13.8 to 1.13.9. - [Release notes](https://github.com/sparklemotion/nokogiri/releases) - [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md) - [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.13.8...v1.13.9) --- updated-dependencies: - dependency-name: nokogiri dependency-type: indirect ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4bac246fe..be6443954 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -198,7 +198,7 @@ GEM net-protocol timeout nio4r (2.5.8) - nokogiri (1.13.8-x86_64-linux) + nokogiri (1.13.9-x86_64-linux) racc (~> 1.4) omniauth (2.1.0) hashie (>= 3.4.6) From dc8daaf4fcb77ca5483abb39c3b69dfb963b0c36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Nov 2022 15:20:44 +0000 Subject: [PATCH 102/968] Bump commonmarker from 0.23.5 to 0.23.6 Bumps [commonmarker](https://github.com/gjtorikian/commonmarker) from 0.23.5 to 0.23.6. - [Release notes](https://github.com/gjtorikian/commonmarker/releases) - [Changelog](https://github.com/gjtorikian/commonmarker/blob/main/CHANGELOG.md) - [Commits](https://github.com/gjtorikian/commonmarker/compare/v0.23.5...v0.23.6) --- updated-dependencies: - dependency-name: commonmarker dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4bac246fe..bfe2a5bf2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,7 +105,7 @@ GEM coffee-script-source execjs coffee-script-source (1.12.2) - commonmarker (0.23.5) + commonmarker (0.23.6) concurrent-ruby (1.1.10) counter_culture (3.2.1) activerecord (>= 4.2) From 43224189afe23591e8f3743259b8101a50f69c02 Mon Sep 17 00:00:00 2001 From: ArtOfCode Date: Thu, 24 Nov 2022 15:47:17 +0000 Subject: [PATCH 103/968] Fix upload redirects --- app/controllers/application_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ce8c81040..97c6e7387 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -17,10 +17,10 @@ class ApplicationController < ActionController::Base def upload if ActiveStorage::Blob.service.class.name.end_with?('S3Service') - redirect_to helpers.upload_remote_url(params[:key]), status: 301 + redirect_to helpers.upload_remote_url(params[:key]), status: 301, allow_other_host: true else blob = params[:key] - redirect_to url_for(ActiveStorage::Blob.find_by(key: blob.is_a?(String) ? blob : blob.key)) + redirect_to url_for(ActiveStorage::Blob.find_by(key: blob.is_a?(String) ? blob : blob.key)), allow_other_host: true end end From 966bcd5b5cab3b95ef2dae655504c3a5a0a314b2 Mon Sep 17 00:00:00 2001 From: ArtOfCode Date: Thu, 24 Nov 2022 23:08:14 +0000 Subject: [PATCH 104/968] Asyncify to fix error --- app/views/layouts/_matomo.html.erb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/views/layouts/_matomo.html.erb b/app/views/layouts/_matomo.html.erb index 819877232..b8b949c3a 100644 --- a/app/views/layouts/_matomo.html.erb +++ b/app/views/layouts/_matomo.html.erb @@ -1,6 +1,7 @@ <% unless SiteSetting['AnalyticsURL'].blank? || SiteSetting['AnalyticsSiteId'].blank? %> <% cache [RequestContext.community, SiteSetting['AnalyticsURL'], SiteSetting['AnalyticsSiteId']] do %>
- <%= f.password_field :password, class: 'form-element', autocomplete: "new-password" %> + <%= f.password_field :password, class: 'form-element', autocomplete: "new-password", disabled: sso %>
<%= f.label :password_confirmation, class: "form-element" %> - <%= f.password_field :password_confirmation, class: 'form-element', autocomplete: "new-password" %> + <%= f.password_field :password_confirmation, class: 'form-element', autocomplete: "new-password", disabled: sso %>
<%= f.label :current_password, class: "form-element" %>
We need your current password to confirm your changes.
- <%= f.password_field :current_password, class: 'form-element', autocomplete: "current-password", required: true %> + <%= f.password_field :current_password, class: 'form-element', autocomplete: "current-password", required: true, + disabled: sso %>
- <%= f.submit "Update", class: 'button is-filled is-very-large' %> + <%= f.submit "Update", class: 'button is-filled is-very-large', disabled: sso %> <% end %>
From bce153ecece252237519cfe93c65f0ee0f2f5aba Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Sat, 26 Nov 2022 18:03:03 +0100 Subject: [PATCH 107/968] Enforce that SSO users sign-in with SSO --- app/controllers/users/sessions_controller.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 6f199bfbe..e086adbd8 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -22,6 +22,14 @@ def create return end + if user.present? && user.sso_profile.present? + sign_out user + flash[:notice] = nil + flash[:danger] = 'Please sign in using the Single Sign On service of your institution.' + redirect_to new_saml_user_session_path + return + end + if user.present? && user.enabled_2fa sign_out user case user.two_factor_method From 2f32d1ce88bc624151a3115c2cd145a91d18d7f7 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Sat, 26 Nov 2022 18:03:43 +0100 Subject: [PATCH 108/968] Allow disconnect account from SSO when mixed --- app/controllers/users_controller.rb | 24 +++++++++++++++++++- app/views/devise/registrations/edit.html.erb | 6 +++++ app/views/users/disconnect_sso.html.erb | 12 ++++++++++ config/routes.rb | 2 ++ db/seeds/site_settings.yml | 8 +++++++ 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 app/views/users/disconnect_sso.html.erb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 808d51427..9d2fe6de4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -5,7 +5,8 @@ class UsersController < ApplicationController include Devise::Controllers::Rememberable before_action :authenticate_user!, only: [:edit_profile, :update_profile, :stack_redirect, :transfer_se_content, - :qr_login_code, :me, :preferences, :set_preference, :my_vote_summary] + :qr_login_code, :me, :preferences, :set_preference, :my_vote_summary, + :disconnect_sso, :confirm_disconnect_sso] before_action :verify_moderator, only: [:mod, :destroy, :soft_delete, :role_toggle, :full_log, :annotate, :annotations, :mod_privileges, :mod_privilege_action] before_action :set_user, only: [:show, :mod, :destroy, :soft_delete, :posts, :role_toggle, :full_log, :activity, @@ -488,6 +489,27 @@ def specific_avatar end end + def disconnect_sso + render layout: 'without_sidebar' + end + + def confirm_disconnect_sso + if current_user.sso_profile.blank? || !SiteSetting['AllowSsoDisconnect'] + flash[:danger] = 'You cannot disable Single Sign On.' + redirect_to edit_user_registration_path + return + end + + if current_user.sso_profile.destroy + current_user.send_reset_password_instructions + flash[:success] = 'Successfully disconnected from Single Sign On. Please see your email to set your password.' + redirect_to edit_user_registration_path + else + flash[:danger] = 'Failed to disconnect from Single Sign On.' + redirect_to user_disconnect_sso_path + end + end + private def set_user diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index b638548ed..f2d06a4fe 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -8,6 +8,12 @@ <% sso = current_user.sso_profile.present? %> <% if sso %> + <% if SiteSetting['AllowSsoDisconnect'] %> + <%= link_to user_disconnect_sso_path, class: 'button is-outlined is-danger' do %> + Disconnect Single Sign On » + <% end %> + <% end %> +
You sign in through a Single Sign On provider. Because of that, you cannot change your email address or password here. diff --git a/app/views/users/disconnect_sso.html.erb b/app/views/users/disconnect_sso.html.erb new file mode 100644 index 000000000..a77262d51 --- /dev/null +++ b/app/views/users/disconnect_sso.html.erb @@ -0,0 +1,12 @@ +

Disconnect from Single Sign On

+

+ You currently sign in using your institutions Single Sign On service. + It is possible to switch to signing in with a normal email address and password by disconnecting your account from + the Single Sign On service. + After disconnecting your account, you will be sent an email to set the password for your account. +

+ +<%= link_to 'Disconnect my account from SSO', + user_confirm_disconnect_sso_path, + method: :post, + class: 'button is-filled is-danger' %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index a7c1997f0..a67a92757 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -184,6 +184,8 @@ patch '/edit/profile', to: 'users#update_profile', as: :update_user_profile get '/me/vote-summary', to: 'users#my_vote_summary', as: :my_vote_summary get '/avatar/:letter/:color/:size', to: 'users#specific_avatar', as: :specific_auto_avatar + get '/disconnect-sso', to: 'users#disconnect_sso', as: :user_disconnect_sso + post '/disconnect-sso', to: 'users#confirm_disconnect_sso', as: :user_confirm_disconnect_sso get '/:id', to: 'users#show', as: :user get '/:id/flags', to: 'flags#history', as: :flag_history get '/:id/activity', to: 'users#activity', as: :user_activity diff --git a/db/seeds/site_settings.yml b/db/seeds/site_settings.yml index 33112b22e..c532835e6 100644 --- a/db/seeds/site_settings.yml +++ b/db/seeds/site_settings.yml @@ -542,3 +542,11 @@ description: > This setting only has an effect when SSO Sign In is enabled. Enables 2FA options (and enforces 2FA for global mods and admins if configured) also for SSO users. When the authentication is outsourced to a Single Sign On provider (which may already require 2FA), it often does not make sense to have an additional 2FA check on top of that. + +- name: AllowSsoDisconnect + value: false + value_type: boolean + community_id: ~ + category: SignInAndSignUp + description: > + This setting only has an effect when SSO Sign In and Mixed Sign In are enabled. Allows users to disconnect their account from SSO and switch over to normal login. From 5653c411025fac2c39abc5056bb158b29d6e5ff1 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Sat, 26 Nov 2022 18:21:30 +0100 Subject: [PATCH 109/968] Check for mixed sign in before allowing SSO disconnect --- app/controllers/users_controller.rb | 2 +- app/views/devise/registrations/edit.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9d2fe6de4..c7e950a0a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -494,7 +494,7 @@ def disconnect_sso end def confirm_disconnect_sso - if current_user.sso_profile.blank? || !SiteSetting['AllowSsoDisconnect'] + if current_user.sso_profile.blank? || !helpers.devise_sign_in_enabled? || !SiteSetting['AllowSsoDisconnect'] flash[:danger] = 'You cannot disable Single Sign On.' redirect_to edit_user_registration_path return diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index f2d06a4fe..9934309c6 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -8,7 +8,7 @@ <% sso = current_user.sso_profile.present? %> <% if sso %> - <% if SiteSetting['AllowSsoDisconnect'] %> + <% if devise_sign_in_enabled? && SiteSetting['AllowSsoDisconnect'] %> <%= link_to user_disconnect_sso_path, class: 'button is-outlined is-danger' do %> Disconnect Single Sign On » <% end %> From 709b6961a56b7bf799d361ef6648ceba3a9ca273 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Sat, 26 Nov 2022 20:41:00 +0100 Subject: [PATCH 110/968] Fix impersonation with SSO --- app/controllers/admin_controller.rb | 7 ++++++- app/views/admin/change_back.html.erb | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index b7453e9ce..6bf41e85d 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -170,7 +170,12 @@ def verify_elevation return not_found unless session[:impersonator_id].present? @impersonator = User.find session[:impersonator_id] - if @impersonator&.valid_password? params[:password] + if @impersonator&.sso_profile.present? + session.delete :impersonator_id + AuditLog.admin_audit(event_type: 'impersonation_end', related: current_user, user: @impersonator) + sign_out @impersonator + redirect_to new_saml_user_session_path + elsif @impersonator&.valid_password? params[:password] session.delete :impersonator_id AuditLog.admin_audit(event_type: 'impersonation_end', related: current_user, user: @impersonator) sign_in @impersonator diff --git a/app/views/admin/change_back.html.erb b/app/views/admin/change_back.html.erb index 26e9e77c0..33cf3c7ea 100644 --- a/app/views/admin/change_back.html.erb +++ b/app/views/admin/change_back.html.erb @@ -1,14 +1,20 @@

Stop impersonating

- You (<%= @impersonator.username %>) are currently impersonating <%= current_user.username %>. To stop - impersonating them, verify your password below + You (<%= @impersonator.username %>) are currently impersonating <%= current_user.username %>. + <% if @impersonator.sso_profile.present? %> + You can stop impersonating them with the button below, after which you will have to sign in again. + <% else %> + To stop impersonating them, verify your password below. + <% end %>

<%= form_tag stop_impersonating_path, class: 'form-horizontal' do %> -
- <%= label_tag :password, 'Password', class: 'form-element' %> - <%= password_field_tag :password, '', class: 'form-element' %> -
+ <% unless @impersonator.sso_profile.present? %> +
+ <%= label_tag :password, 'Password', class: 'form-element' %> + <%= password_field_tag :password, '', class: 'form-element' %> +
+ <% end %>
<%= submit_tag 'Verify & Stop Impersonating', class: 'button is-primary is-filled' %> From 0f1aeae5d7268b03cbd8ade9f59e4be9b4135b2c Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Sat, 26 Nov 2022 22:10:25 +0100 Subject: [PATCH 111/968] Consistently use Single Sign-On rather than Single Sign On --- app/controllers/users/sessions_controller.rb | 2 +- app/controllers/users_controller.rb | 6 +++--- app/views/devise/registrations/edit.html.erb | 4 ++-- app/views/two_factor/tf_status.html.erb | 2 +- app/views/users/disconnect_sso.html.erb | 6 +++--- db/seeds/site_settings.yml | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index e086adbd8..45899e5dd 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -25,7 +25,7 @@ def create if user.present? && user.sso_profile.present? sign_out user flash[:notice] = nil - flash[:danger] = 'Please sign in using the Single Sign On service of your institution.' + flash[:danger] = 'Please sign in using the Single Sign-On service of your institution.' redirect_to new_saml_user_session_path return end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c7e950a0a..9820adb4d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -495,17 +495,17 @@ def disconnect_sso def confirm_disconnect_sso if current_user.sso_profile.blank? || !helpers.devise_sign_in_enabled? || !SiteSetting['AllowSsoDisconnect'] - flash[:danger] = 'You cannot disable Single Sign On.' + flash[:danger] = 'You cannot disable Single Sign-On.' redirect_to edit_user_registration_path return end if current_user.sso_profile.destroy current_user.send_reset_password_instructions - flash[:success] = 'Successfully disconnected from Single Sign On. Please see your email to set your password.' + flash[:success] = 'Successfully disconnected from Single Sign-On. Please see your email to set your password.' redirect_to edit_user_registration_path else - flash[:danger] = 'Failed to disconnect from Single Sign On.' + flash[:danger] = 'Failed to disconnect from Single Sign-On.' redirect_to user_disconnect_sso_path end end diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 9934309c6..6c4e20c8d 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -10,12 +10,12 @@ <% if sso %> <% if devise_sign_in_enabled? && SiteSetting['AllowSsoDisconnect'] %> <%= link_to user_disconnect_sso_path, class: 'button is-outlined is-danger' do %> - Disconnect Single Sign On » + Disconnect Single Sign-On » <% end %> <% end %>
- You sign in through a Single Sign On provider. + You sign in through a Single Sign-On provider. Because of that, you cannot change your email address or password here. Please contact your system administrator if you would like to change these.
diff --git a/app/views/two_factor/tf_status.html.erb b/app/views/two_factor/tf_status.html.erb index bbab785d6..5fa90399d 100644 --- a/app/views/two_factor/tf_status.html.erb +++ b/app/views/two_factor/tf_status.html.erb @@ -31,7 +31,7 @@

Enable two-factor authentication

<% if current_user.sso_profile.present? && !SiteSetting['Enable2FAForSsoUsers'] %>

- Because you sign in with Single Sign On, you cannot enable two-factor authentication. + Because you sign in with Single Sign-On, you cannot enable two-factor authentication.

<% else %> <%= form_tag two_factor_enable_path do %> diff --git a/app/views/users/disconnect_sso.html.erb b/app/views/users/disconnect_sso.html.erb index a77262d51..b55f669fb 100644 --- a/app/views/users/disconnect_sso.html.erb +++ b/app/views/users/disconnect_sso.html.erb @@ -1,8 +1,8 @@ -

Disconnect from Single Sign On

+

Disconnect from Single Sign-On

- You currently sign in using your institutions Single Sign On service. + You currently sign in using your institutions Single Sign-On service. It is possible to switch to signing in with a normal email address and password by disconnecting your account from - the Single Sign On service. + the Single Sign-On service. After disconnecting your account, you will be sent an email to set the password for your account.

diff --git a/db/seeds/site_settings.yml b/db/seeds/site_settings.yml index c532835e6..0104be1ee 100644 --- a/db/seeds/site_settings.yml +++ b/db/seeds/site_settings.yml @@ -541,7 +541,7 @@ category: SignInAndSignUp description: > This setting only has an effect when SSO Sign In is enabled. Enables 2FA options (and enforces 2FA for global mods and admins if configured) also for SSO users. - When the authentication is outsourced to a Single Sign On provider (which may already require 2FA), it often does not make sense to have an additional 2FA check on top of that. + When the authentication is outsourced to a Single Sign-On provider (which may already require 2FA), it often does not make sense to have an additional 2FA check on top of that. - name: AllowSsoDisconnect value: false From bf4fbcc9f2bc204c885647935c26fc2395b06795 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 8 Dec 2022 17:39:18 -0800 Subject: [PATCH 112/968] Update preference config schema comment --- config/config/preferences.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/config/preferences.yml b/config/config/preferences.yml index 927d7fb56..76c90a211 100644 --- a/config/config/preferences.yml +++ b/config/config/preferences.yml @@ -10,7 +10,10 @@ # - if # - type is choice # default: string default value | ~ -# community: true | false # optional, default false (global) +## Either community, global or category should be set to true, all default to false +# community: true | false +# global: true | false +# category: true | false keyboard_tools: type: boolean From 8e9b343cc7d7dd4438747ef09d0f75c77b82c465 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 8 Dec 2022 17:44:34 -0800 Subject: [PATCH 113/968] Change var to let --- app/assets/javascripts/filters.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/filters.js b/app/assets/javascripts/filters.js index 1f190d810..f080f492a 100644 --- a/app/assets/javascripts/filters.js +++ b/app/assets/javascripts/filters.js @@ -6,7 +6,7 @@ $(() => { const $saveButton = $form.find('.filter-save'); const $isDefaultCheckbox = $form.find('.filter-is-default'); const categoryId = $isDefaultCheckbox.val(); - var defaultFilter = await QPixel.defaultFilter(categoryId); + let defaultFilter = await QPixel.defaultFilter(categoryId); const $deleteButton = $form.find('.filter-delete'); // Enables/Disables Save & Delete buttons programatically From 50cd4c8e1d2f7572538580970a38d909a89c37c9 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 8 Dec 2022 17:47:44 -0800 Subject: [PATCH 114/968] Consistantly use CamelCased variables --- app/assets/javascripts/qpixel_api.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index ebc40db07..e61fc7ff8 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -295,8 +295,8 @@ window.QPixel = { return data.name; }, - setFilterAsDefault: async (category_id, name) => { - const resp = await fetch(`/categories/${category_id}/filters/default`, { + setFilterAsDefault: async (categoryId, name) => { + const resp = await fetch(`/categories/${categoryId}/filters/default`, { method: 'POST', credentials: 'include', headers: { @@ -308,7 +308,7 @@ window.QPixel = { }); }, - setFilter: async (name, filter, category, is_default) => { + setFilter: async (name, filter, category, isDefault) => { const resp = await fetch('/users/me/filters', { method: 'POST', credentials: 'include', @@ -317,7 +317,7 @@ window.QPixel = { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify(Object.assign(filter, { name, category, is_default })) + body: JSON.stringify(Object.assign(filter, { name, category, is_default: isDefault })) }); const data = await resp.json(); if (data.status !== 'success') { From d143acb9f8c84c2b1a5d4c8015a7f12a85af9198 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 8 Dec 2022 18:09:29 -0800 Subject: [PATCH 115/968] Update test/fixtures/category_filter_defaults.yml Co-authored-by: Taico Aerts --- test/fixtures/category_filter_defaults.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/fixtures/category_filter_defaults.yml b/test/fixtures/category_filter_defaults.yml index f1a454af5..697b14ee7 100644 --- a/test/fixtures/category_filter_defaults.yml +++ b/test/fixtures/category_filter_defaults.yml @@ -1,11 +1,11 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -one: - user: one +main_default_filter: + user: standard_user filter: one - category: one + category: main -two: - user: two - filter: two - category: two +meta_default_filter: + user: standard_user + filter: one + category: meta From 6691585895296e5779424a7ca4a2fbe530be4221 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 8 Dec 2022 18:34:11 -0800 Subject: [PATCH 116/968] Update config/config/preferences.yml Co-authored-by: Taico Aerts --- config/config/preferences.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config/preferences.yml b/config/config/preferences.yml index 76c90a211..28fc48eca 100644 --- a/config/config/preferences.yml +++ b/config/config/preferences.yml @@ -77,7 +77,7 @@ sticky_header: type: boolean description: > Make the top navigation bar sticky. - default: false + default: 'false' global: true default_filter_name: From 8c5e7e993d30609fa0d27723d539774bee8f3bfc Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Thu, 8 Dec 2022 18:39:27 -0800 Subject: [PATCH 117/968] Set priority of Users and Filters --- db/seeds.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/seeds.rb b/db/seeds.rb index 9a5c85790..33d92de87 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -16,7 +16,7 @@ end # Prioritize the following models (in this order) such that models depending on them get created after -priority = [PostType, CloseReason, License, TagSet, PostHistoryType] +priority = [PostType, CloseReason, License, TagSet, PostHistoryType, User, Filter] sorted = files.zip(types).to_h.sort do |a, b| (priority.index(a.second) || 999) <=> (priority.index(b.second) || 999) end.to_h From 474dc3ae398a2e536993bc03362afbaa3aa471a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Dec 2022 20:15:22 +0000 Subject: [PATCH 118/968] Bump loofah from 2.18.0 to 2.19.1 Bumps [loofah](https://github.com/flavorjones/loofah) from 2.18.0 to 2.19.1. - [Release notes](https://github.com/flavorjones/loofah/releases) - [Changelog](https://github.com/flavorjones/loofah/blob/main/CHANGELOG.md) - [Commits](https://github.com/flavorjones/loofah/compare/v2.18.0...v2.19.1) --- updated-dependencies: - dependency-name: loofah dependency-type: indirect ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2d27988d5..db78a1547 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -169,7 +169,7 @@ GEM listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.18.0) + loofah (2.19.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -201,7 +201,7 @@ GEM net-protocol timeout nio4r (2.5.8) - nokogiri (1.13.9-x86_64-linux) + nokogiri (1.13.10-x86_64-linux) racc (~> 1.4) omniauth (2.1.0) hashie (>= 3.4.6) @@ -221,7 +221,7 @@ GEM public_suffix (5.0.0) puma (5.6.5) nio4r (~> 2.0) - racc (1.6.0) + racc (1.6.1) rack (2.2.4) rack-mini-profiler (3.0.0) rack (>= 1.2.0) From 4ef168be9f86e558d068c059bf210f8dacbad8dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Dec 2022 15:12:34 +0000 Subject: [PATCH 119/968] Bump rails-html-sanitizer from 1.4.3 to 1.4.4 Bumps [rails-html-sanitizer](https://github.com/rails/rails-html-sanitizer) from 1.4.3 to 1.4.4. - [Release notes](https://github.com/rails/rails-html-sanitizer/releases) - [Changelog](https://github.com/rails/rails-html-sanitizer/blob/master/CHANGELOG.md) - [Commits](https://github.com/rails/rails-html-sanitizer/compare/v1.4.3...v1.4.4) --- updated-dependencies: - dependency-name: rails-html-sanitizer dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2d27988d5..91d90fcbf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -169,7 +169,7 @@ GEM listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.18.0) + loofah (2.19.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -201,7 +201,7 @@ GEM net-protocol timeout nio4r (2.5.8) - nokogiri (1.13.9-x86_64-linux) + nokogiri (1.13.10-x86_64-linux) racc (~> 1.4) omniauth (2.1.0) hashie (>= 3.4.6) @@ -221,7 +221,7 @@ GEM public_suffix (5.0.0) puma (5.6.5) nio4r (~> 2.0) - racc (1.6.0) + racc (1.6.1) rack (2.2.4) rack-mini-profiler (3.0.0) rack (>= 1.2.0) @@ -250,8 +250,8 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.4.3) - loofah (~> 2.3) + rails-html-sanitizer (1.4.4) + loofah (~> 2.19, >= 2.19.1) railties (7.0.4) actionpack (= 7.0.4) activesupport (= 7.0.4) From b283e4ac38bd263ad199072c54a7be2be164fe3e Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 20 Dec 2022 14:50:31 -0800 Subject: [PATCH 120/968] Update test/fixtures/filters.yml Co-authored-by: Taico Aerts --- test/fixtures/filters.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/test/fixtures/filters.yml b/test/fixtures/filters.yml index 8d33b4a27..adb5d4e90 100644 --- a/test/fixtures/filters.yml +++ b/test/fixtures/filters.yml @@ -10,6 +10,7 @@ one: two: name: MyString + user: standard_user min_score: 1.5 max_score: 1.5 min_answers: 1 From db73643c3521a684c40f91b8566afc033f95ff2a Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 20 Dec 2022 14:50:47 -0800 Subject: [PATCH 121/968] Update test/fixtures/filters.yml Co-authored-by: Taico Aerts --- test/fixtures/filters.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/test/fixtures/filters.yml b/test/fixtures/filters.yml index adb5d4e90..05e9cf336 100644 --- a/test/fixtures/filters.yml +++ b/test/fixtures/filters.yml @@ -2,6 +2,7 @@ one: name: MyString + user: standard_user min_score: 1.5 max_score: 1.5 min_answers: 1 From cf06f5b0942e3e47863d105d2cd414fb08bc0be2 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 20 Dec 2022 15:02:41 -0800 Subject: [PATCH 122/968] Update app/models/user.rb Co-authored-by: Taico Aerts --- app/models/user.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/user.rb b/app/models/user.rb index 421078a14..4fb41c80e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,6 +25,7 @@ class User < ApplicationRecord has_many :comment_threads_deleted, class_name: 'CommentThread', foreign_key: :deleted_by_id, dependent: :nullify has_many :comment_threads_locked, class_name: 'CommentThread', foreign_key: :locked_by_id, dependent: :nullify has_many :filters, dependent: :destroy + has_many :category_filter_defaults, dependent: :destroy belongs_to :deleted_by, required: false, class_name: 'User' validates :username, presence: true, length: { minimum: 3, maximum: 50 } From 2e1c7e593e5a6d70d4d6f8488f989e40c6489f7f Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 21 Dec 2022 14:56:57 -0800 Subject: [PATCH 123/968] Fix order of dependents' destruction --- app/models/user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index 4fb41c80e..7a872721f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,8 +24,8 @@ class User < ApplicationRecord has_many :comments, dependent: :nullify has_many :comment_threads_deleted, class_name: 'CommentThread', foreign_key: :deleted_by_id, dependent: :nullify has_many :comment_threads_locked, class_name: 'CommentThread', foreign_key: :locked_by_id, dependent: :nullify - has_many :filters, dependent: :destroy has_many :category_filter_defaults, dependent: :destroy + has_many :filters, dependent: :destroy belongs_to :deleted_by, required: false, class_name: 'User' validates :username, presence: true, length: { minimum: 3, maximum: 50 } From e5504c2ac614f6dfac4fbadf40f22ef770073db9 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 21 Dec 2022 15:53:21 -0800 Subject: [PATCH 124/968] Implement user:me search --- app/helpers/search_helper.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 311a1cdbb..f3b494394 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -116,9 +116,14 @@ def parse_qualifier_strings(qualifiers) operator, val, timeframe = date_value_sql value { param: :created, operator: operator.presence || '=', timeframe: timeframe, value: val.to_i } when 'user' - next unless value.match?(valid_value[:numeric]) + operator, val = if value.match?(valid_value[:numeric]) + numeric_value_sql value + elsif value == 'me' + ['=', current_user.id] + else + next + end - operator, val = numeric_value_sql value { param: :user, operator: operator.presence || '=', user_id: val.to_i } when 'upvotes' next unless value.match?(valid_value[:numeric]) From f95d41bb998c40b5589d7fb634d8f9ed7d6c3532 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 21 Dec 2022 16:42:41 -0800 Subject: [PATCH 125/968] Rename param parsing method --- app/helpers/search_helper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index f3b494394..aa141202f 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -4,7 +4,7 @@ def search_posts posts = (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted) .qa_only.list_includes - qualifiers = filters_to_qualifiers + qualifiers = params_to_qualifiers search_string = params[:search] # Filter based on search string qualifiers @@ -39,7 +39,7 @@ def filter_to_qualifiers(filter) qualifiers end - def filters_to_qualifiers + def params_to_qualifiers valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, status: /any|open|closed/, From b205c3ed494de21f142f770abefd690c8187c7c6 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 21 Dec 2022 16:57:37 -0800 Subject: [PATCH 126/968] Fix error on invalid qualifiers --- app/helpers/search_helper.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index aa141202f..a74c3ca07 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -164,7 +164,8 @@ def parse_qualifier_strings(qualifiers) { param: :status, value: value } end - end + end.reject(&:nil?) + # Consider partitioning and telling the user which filters were invalid end def qualifiers_to_sql(qualifiers, query) From c42fbaca53f068f036c20a5e4c1e0402a87a16c6 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 27 Dec 2022 20:27:08 -0800 Subject: [PATCH 127/968] Fix missed rename --- app/controllers/categories_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index dec072762..841c14785 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -162,7 +162,7 @@ def set_list_posts sort_param = sort_params[params[:sort]&.to_sym] || { last_activity: :desc } @posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types) .includes(:post_type, :tags).list_includes - filter_qualifiers = helpers.filters_to_qualifiers + filter_qualifiers = helpers.params_to_qualifiers @active_filter = { default: false, From af661b93842cab7c2267eebca54f6e159f6315b5 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 27 Dec 2022 20:27:22 -0800 Subject: [PATCH 128/968] Add uniqueness constraint to filter name --- app/models/filter.rb | 1 + test/fixtures/filters.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/filter.rb b/app/models/filter.rb index 6579be7de..2fd752b07 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -1,5 +1,6 @@ class Filter < ApplicationRecord belongs_to :user + validates :name, uniqueness: { scope: :user } serialize :include_tags, Array serialize :exclude_tags, Array end diff --git a/test/fixtures/filters.yml b/test/fixtures/filters.yml index 05e9cf336..f47aa1e31 100644 --- a/test/fixtures/filters.yml +++ b/test/fixtures/filters.yml @@ -1,7 +1,7 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - name: MyString + name: MyFilterOne user: standard_user min_score: 1.5 max_score: 1.5 @@ -10,7 +10,7 @@ one: status: MyString two: - name: MyString + name: MyFilterTwo user: standard_user min_score: 1.5 max_score: 1.5 From ade7b35a82ee3d59452f652a28f0672b607d627d Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 27 Dec 2022 20:45:21 -0800 Subject: [PATCH 129/968] Fill in the active filter in search --- app/controllers/categories_controller.rb | 13 +------------ app/controllers/search_controller.rb | 1 + app/helpers/search_helper.rb | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 841c14785..edd7dc0cc 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -163,18 +163,7 @@ def set_list_posts @posts = @category.posts.undeleted.where(post_type_id: @category.display_post_types) .includes(:post_type, :tags).list_includes filter_qualifiers = helpers.params_to_qualifiers - - @active_filter = { - default: false, - name: params[:predefined_filter], - min_score: params[:min_score], - max_score: params[:max_score], - min_answers: params[:min_answers], - max_answers: params[:max_answers], - include_tags: params[:include_tags], - exclude_tags: params[:exclude_tags], - status: params[:status] - } + @active_filter = helpers.active_filter if filter_qualifiers.blank? && user_signed_in? default_filter_id = helpers.default_filter(current_user.id, @category.id) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 27caba47a..476ed5d92 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,6 +1,7 @@ class SearchController < ApplicationController def search @posts = helpers.search_posts + @active_filter = helpers.active_filter @count = begin @posts&.count rescue diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index a74c3ca07..b70d7441a 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -39,6 +39,20 @@ def filter_to_qualifiers(filter) qualifiers end + def active_filter() + { + default: false, + name: params[:predefined_filter], + min_score: params[:min_score], + max_score: params[:max_score], + min_answers: params[:min_answers], + max_answers: params[:max_answers], + include_tags: params[:include_tags], + exclude_tags: params[:exclude_tags], + status: params[:status] + } + end + def params_to_qualifiers valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, From e45a960eb58295c1bee766c11d493108c1135a6d Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 28 Dec 2022 15:59:22 -0800 Subject: [PATCH 130/968] Fix double bracketed footnotes --- app/assets/stylesheets/application.scss | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 2b105d33f..4f81f744e 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -145,22 +145,6 @@ hr { } } -.footnote-ref a::before { - content: '['; -} - -div.post-preview .footnote-ref a::before { - content: ''; -} - -.footnote-ref a::after { - content: ']'; -} - -div.post-preview .footnote-ref a::after { - content: ''; -} - .footnotes-sep + .footnotes { border-top: 0; } From 40dda8b4d6f8984511a2f22c5aaf7f07ce2b9fdd Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Wed, 28 Dec 2022 16:51:15 -0800 Subject: [PATCH 131/968] Prevent selection of nonexistent parent tag --- app/views/tags/_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb index f88e1d6d4..690a57bc2 100644 --- a/app/views/tags/_form.html.erb +++ b/app/views/tags/_form.html.erb @@ -31,7 +31,7 @@ <%= f.select :parent_id, options_for_select(@tag.parent.present? ? [[@tag.parent.name, @tag.parent_id]] : [], selected: @tag.parent.present? ? @tag.parent_id : nil), { include_blank: true }, class: "form-element js-tag-select", - data: { tag_set: @category.tag_set_id, use_ids: true, placeholder: "None" } %> + data: { tag_set: @category.tag_set_id, create: false, use_ids: true, placeholder: "None" } %>
From d4ee4c483d9a4052724ddb05220251e6a547dc1d Mon Sep 17 00:00:00 2001 From: Monica Cellio Date: Mon, 2 Jan 2023 14:42:33 -0500 Subject: [PATCH 132/968] changed order of fields (current pwd first, then new pwd & confirmation) --- app/views/devise/registrations/edit.html.erb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 6c4e20c8d..4c7f085f5 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -33,6 +33,13 @@
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
<% end %> +
+ <%= f.label :current_password, class: "form-element" %> +
We need your current password to confirm your changes.
+ <%= f.password_field :current_password, class: 'form-element', autocomplete: "current-password", required: true, + disabled: sso %> +
+
<%= f.label :password, class: "form-element" %>
@@ -49,12 +56,5 @@ <%= f.password_field :password_confirmation, class: 'form-element', autocomplete: "new-password", disabled: sso %>
-
- <%= f.label :current_password, class: "form-element" %> -
We need your current password to confirm your changes.
- <%= f.password_field :current_password, class: 'form-element', autocomplete: "current-password", required: true, - disabled: sso %> -
- <%= f.submit "Update", class: 'button is-filled is-very-large', disabled: sso %> <% end %>
From 2b1a5baef6f090ba336b729ddc33120087e0bbf1 Mon Sep 17 00:00:00 2001 From: ArtOfCode Date: Mon, 2 Jan 2023 19:50:09 +0000 Subject: [PATCH 133/968] Remove caching that killed the server --- app/views/layouts/_matomo.html.erb | 48 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/app/views/layouts/_matomo.html.erb b/app/views/layouts/_matomo.html.erb index b8b949c3a..8ef62a3b0 100644 --- a/app/views/layouts/_matomo.html.erb +++ b/app/views/layouts/_matomo.html.erb @@ -1,30 +1,28 @@ <% unless SiteSetting['AnalyticsURL'].blank? || SiteSetting['AnalyticsSiteId'].blank? %> - <% cache [RequestContext.community, SiteSetting['AnalyticsURL'], SiteSetting['AnalyticsSiteId']] do %> - - - <% end %> + })(); + + <% end %> From 8003168406cfe7beac69886f7db625b423b6fff5 Mon Sep 17 00:00:00 2001 From: Monica Cellio Date: Mon, 2 Jan 2023 14:56:11 -0500 Subject: [PATCH 134/968] clarified field labels --- app/views/devise/registrations/edit.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 4c7f085f5..644611675 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -41,7 +41,7 @@
- <%= f.label :password, class: "form-element" %> + <%= f.label :password, "New password", class: "form-element" %>
Leave blank if you don't want to change it. <% if @minimum_password_length %> @@ -52,7 +52,7 @@
- <%= f.label :password_confirmation, class: "form-element" %> + <%= f.label :password_confirmation, "Confirm new password", class: "form-element" %> <%= f.password_field :password_confirmation, class: 'form-element', autocomplete: "new-password", disabled: sso %>
From e6e09c8c878e6aebf761036b6c27ce870f6063e5 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Mon, 2 Jan 2023 22:02:26 -0800 Subject: [PATCH 135/968] Move see-all-notifications button to top --- app/assets/javascripts/notifications.js | 2 -- app/views/layouts/_header.html.erb | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/javascripts/notifications.js b/app/assets/javascripts/notifications.js index 269a3ff59..474f7faef 100644 --- a/app/assets/javascripts/notifications.js +++ b/app/assets/javascripts/notifications.js @@ -67,8 +67,6 @@ $(() => { const item = $(makeNotification(notification)); $inboxContainer.append(item); }); - - $inboxContainer.append(`See all your notifications »`); } }); diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 32fa3b3f3..8fc02d236 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -167,6 +167,7 @@
Notifications
From 6d1822758423d2ea253ced85249427c26198ebfc Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Mon, 2 Jan 2023 22:12:48 -0800 Subject: [PATCH 136/968] Add notifications tab to user page --- app/views/notifications/index.html.erb | 2 ++ app/views/users/_tabs.html.erb | 3 +++ 2 files changed, 5 insertions(+) diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb index 0672c4558..33c2be2fb 100644 --- a/app/views/notifications/index.html.erb +++ b/app/views/notifications/index.html.erb @@ -1,3 +1,5 @@ +<%= render 'users/tabs', user: current_user %> +

Your Inbox

You'll find all your inbox messages here, as far back as your account goes.

diff --git a/app/views/users/_tabs.html.erb b/app/views/users/_tabs.html.erb index 0c4713fef..9f093d3ed 100644 --- a/app/views/users/_tabs.html.erb +++ b/app/views/users/_tabs.html.erb @@ -18,5 +18,8 @@ <%= link_to user_preferences_path, class: "tabs--item #{current_page?(user_preferences_path) ? 'is-active' : ''}" do %> Preferences <% end %> + <%= link_to notifications_path, class: "tabs--item #{current_page?(notifications_path) ? 'is-active' : ''}" do %> + Notifications + <% end %> <% end %>
\ No newline at end of file From d996ff1e6df0de7dfe3634ff39f4f56dd1e26697 Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 3 Jan 2023 01:49:38 -0800 Subject: [PATCH 137/968] Add indicator if there are pending suggested edits --- app/views/layouts/_header.html.erb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 32fa3b3f3..4ba7f8143 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -244,8 +244,13 @@ class: "category-header--nav-item #{active?(current_cat) && !['tags', 'suggested_edit'].include?(controller_name) ? 'is-active' : ''}" %> <%= link_to 'Tags', category_tags_path(current_cat), class: "category-header--nav-item #{active?(current_cat) && controller_name == 'tags' ? 'is-active' : ''}" %> - <%= link_to 'Edits', suggested_edits_queue_path(current_cat), - class: "category-header--nav-item #{active?(current_cat) && controller_name == 'suggested_edit' ? 'is-active' : ''}" %> + <%= link_to suggested_edits_queue_path(current_cat), + class: "category-header--nav-item #{active?(current_cat) && controller_name == 'suggested_edit' ? 'is-active' : ''}" do %> + Edits + <% if SuggestedEdit.where(post: Post.undeleted.where(category: current_cat), active: true).any? %> + + <% end %> + <% end %>
<%= link_to category_post_types_path(current_cat.id), class: 'category-header--nav-item is-button' do %> From 82ef37fa27ad37d34ca5ae1a516b731f52f0d48e Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 3 Jan 2023 02:09:51 -0800 Subject: [PATCH 138/968] Cache whether there are pending suggestions --- app/models/suggested_edit.rb | 6 ++++++ app/views/layouts/_header.html.erb | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/models/suggested_edit.rb b/app/models/suggested_edit.rb index 7730bbc61..278691e28 100644 --- a/app/models/suggested_edit.rb +++ b/app/models/suggested_edit.rb @@ -10,6 +10,12 @@ class SuggestedEdit < ApplicationRecord has_and_belongs_to_many :tags has_and_belongs_to_many :before_tags, class_name: 'Tag', join_table: 'suggested_edits_before_tags' + after_save :clear_pending_cache + + def clear_pending_cache + Rails.cache.delete "pending_suggestions/#{post.category_id}" + end + def pending? active end diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 4ba7f8143..7f439e333 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -247,7 +247,10 @@ <%= link_to suggested_edits_queue_path(current_cat), class: "category-header--nav-item #{active?(current_cat) && controller_name == 'suggested_edit' ? 'is-active' : ''}" do %> Edits - <% if SuggestedEdit.where(post: Post.undeleted.where(category: current_cat), active: true).any? %> + <% has_pending = Rails.cache.fetch "pending_suggestions/#{current_cat.id}" do %> + <% SuggestedEdit.where(post: Post.undeleted.where(category: current_cat), active: true).any? %> + <% end %> + <% if has_pending %> <% end %> <% end %> From 597c70a1dde87a1952d5e85c76f81e04bc69664b Mon Sep 17 00:00:00 2001 From: MoshiKoi <54333972+MoshiKoi@users.noreply.github.com> Date: Tue, 3 Jan 2023 07:16:20 -0800 Subject: [PATCH 139/968] Move pending logic to helper method --- app/helpers/categories_helper.rb | 6 ++++++ app/views/layouts/_header.html.erb | 5 +---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index 68c77d120..7efbc1617 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -23,4 +23,10 @@ def current_category @article.category end end + + def pending_suggestions? + Rails.cache.fetch "pending_suggestions/#{current_category.id}" do + SuggestedEdit.where(post: Post.undeleted.where(category: current_category), active: true).any? + end + end end diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 7f439e333..26ea7774c 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -247,10 +247,7 @@ <%= link_to suggested_edits_queue_path(current_cat), class: "category-header--nav-item #{active?(current_cat) && controller_name == 'suggested_edit' ? 'is-active' : ''}" do %> Edits - <% has_pending = Rails.cache.fetch "pending_suggestions/#{current_cat.id}" do %> - <% SuggestedEdit.where(post: Post.undeleted.where(category: current_cat), active: true).any? %> - <% end %> - <% if has_pending %> + <% if pending_suggestions? %> <% end %> <% end %> From 10c14745917a59d457d04137fa1235e665a40864 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Tue, 3 Jan 2023 18:46:34 +0100 Subject: [PATCH 140/968] Fix tour test for the 3rd time Also add tests to ensure that the tagset is correctly created by the seeds. --- app/controllers/tour_controller.rb | 4 +++- app/views/tour/question2.html.erb | 2 +- db/seeds/tag_sets.yml | 4 +--- test/seeds/tour_test.rb | 16 ++++++++++++++++ 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 test/seeds/tour_test.rb diff --git a/app/controllers/tour_controller.rb b/app/controllers/tour_controller.rb index b7101473d..13ea7f27e 100644 --- a/app/controllers/tour_controller.rb +++ b/app/controllers/tour_controller.rb @@ -5,7 +5,9 @@ def index; end def question1; end - def question2; end + def question2 + @tagset_id = TagSet.find_by(name: 'Tour')&.id || -1 + end def question3; end diff --git a/app/views/tour/question2.html.erb b/app/views/tour/question2.html.erb index 008550ba3..f5d4cd6db 100644 --- a/app/views/tour/question2.html.erb +++ b/app/views/tour/question2.html.erb @@ -43,7 +43,7 @@
- +
diff --git a/db/seeds/tag_sets.yml b/db/seeds/tag_sets.yml index d675cb51a..9dfe81cf2 100644 --- a/db/seeds/tag_sets.yml +++ b/db/seeds/tag_sets.yml @@ -1,5 +1,3 @@ - name: Main - name: Meta -- name: Tour - id: -1 - community_id: 1 \ No newline at end of file +- name: Tour \ No newline at end of file diff --git a/test/seeds/tour_test.rb b/test/seeds/tour_test.rb new file mode 100644 index 000000000..4ae732368 --- /dev/null +++ b/test/seeds/tour_test.rb @@ -0,0 +1,16 @@ +require 'test_helper' + +class TourTest < ActiveSupport::TestCase + test "Tour question tag set exists for all communities after seeding" do + # Ensure there are multiple communities + c1 = Community.create(name: 'Test 1', host: 'test.host.1') + c2 = Community.create(name: 'Test 2', host: 'test.host.2') + + Rails.application.load_seed + + # Every community should have the Tour tagset + Community.all.each do |c| + assert_not_nil TagSet.unscoped.where(community: c).find_by('Tour') + end + end +end \ No newline at end of file From 9c91ff46d80ebb0a87e2e762ba87431641b6f8ef Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Tue, 3 Jan 2023 21:07:15 +0100 Subject: [PATCH 141/968] Fix CI issues - Fix rubocop errors accidentally merged - Fix failing test --- app/controllers/application_controller.rb | 3 +- .../users/saml_sessions_controller.rb | 3 +- app/controllers/users/sessions_controller.rb | 33 +++++++++++-------- app/models/application_record.rb | 8 ++--- test/helpers/comments_helper_test.rb | 2 +- test/seeds/tour_test.rb | 10 +++--- 6 files changed, 33 insertions(+), 26 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d6ce97914..f323a6caa 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -20,7 +20,8 @@ def upload redirect_to helpers.upload_remote_url(params[:key]), status: 301, allow_other_host: true else blob = params[:key] - redirect_to url_for(ActiveStorage::Blob.find_by(key: blob.is_a?(String) ? blob : blob.key)), allow_other_host: true + redirect_to url_for(ActiveStorage::Blob.find_by(key: blob.is_a?(String) ? blob : blob.key)), + allow_other_host: true end end diff --git a/app/controllers/users/saml_sessions_controller.rb b/app/controllers/users/saml_sessions_controller.rb index ec5ed11ad..ef22a4b13 100644 --- a/app/controllers/users/saml_sessions_controller.rb +++ b/app/controllers/users/saml_sessions_controller.rb @@ -4,7 +4,8 @@ class Users::SamlSessionsController < Devise::SamlSessionsController def create super do |user| if user.deleted? || user.community_user&.deleted? - # The IDP already confirmed the sign in, so we can't fool the user any more that their credentials were incorrect. + # The IDP already confirmed the sign in, so we can't fool the user any more that their credentials were + # incorrect. sign_out user flash[:notice] = nil flash[:danger] = 'We could not sign you in because of an issue with your account.' diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 45899e5dd..32d1d784d 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -31,20 +31,8 @@ def create end if user.present? && user.enabled_2fa - sign_out user - case user.two_factor_method - when 'app' - id = user.id - @@first_factor << id - redirect_to login_verify_2fa_path(uid: id) - return - when 'email' - TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now - flash[:notice] = nil - flash[:info] = 'Please check your email inbox for a link to sign in.' - redirect_to root_path - return - end + handle_2fa_login(user) + return end end end @@ -81,4 +69,21 @@ def verify_code redirect_to login_verify_2fa_path(uid: params[:uid]) end end + + private + + def handle_2fa_login(user) + sign_out user + case user.two_factor_method + when 'app' + id = user.id + @@first_factor << id + redirect_to login_verify_2fa_path(uid: id) + when 'email' + TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now + flash[:notice] = nil + flash[:info] = 'Please check your email inbox for a link to sign in.' + redirect_to root_path + end + end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 0e552701f..2f0d184b0 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -107,11 +107,11 @@ def user_sort(term_opts, **field_mappings) end end -klasses = [::ActiveRecord::Relation] -klasses << if defined? ::ActiveRecord::Associations::CollectionProxy - ::ActiveRecord::Associations::CollectionProxy +klasses = [ActiveRecord::Relation] +klasses << if defined? ActiveRecord::Associations::CollectionProxy + ActiveRecord::Associations::CollectionProxy else - ::ActiveRecord::Associations::AssociationCollection + ActiveRecord::Associations::AssociationCollection end ActiveRecord::Base.extend UserSortable diff --git a/test/helpers/comments_helper_test.rb b/test/helpers/comments_helper_test.rb index fca0964bd..42c6bde74 100644 --- a/test/helpers/comments_helper_test.rb +++ b/test/helpers/comments_helper_test.rb @@ -28,7 +28,7 @@ class CommentsHelperTest < ActionView::TestCase test '[flags?] substitution' do expected = { '[flag] me if you can' => "flag me if you can", - '\'cause it\'s our [flags]hip product' => "\'cause it\'s our flagship product", + '\'cause it\'s our [flags]hip product' => "'cause it's our flagship product", 'yeah bad pun - [flagged] and downvoted' => 'yeah bad pun - [flagged] and downvoted' } expected.each do |input, expect| diff --git a/test/seeds/tour_test.rb b/test/seeds/tour_test.rb index 4ae732368..1d7124af6 100644 --- a/test/seeds/tour_test.rb +++ b/test/seeds/tour_test.rb @@ -1,16 +1,16 @@ require 'test_helper' class TourTest < ActiveSupport::TestCase - test "Tour question tag set exists for all communities after seeding" do + test 'Tour question tag set exists for all communities after seeding' do # Ensure there are multiple communities - c1 = Community.create(name: 'Test 1', host: 'test.host.1') - c2 = Community.create(name: 'Test 2', host: 'test.host.2') + Community.create(name: 'Test 1', host: 'test.host.1') + Community.create(name: 'Test 2', host: 'test.host.2') Rails.application.load_seed # Every community should have the Tour tagset Community.all.each do |c| - assert_not_nil TagSet.unscoped.where(community: c).find_by('Tour') + assert_not_nil TagSet.unscoped.where(community: c).find_by(name: 'Tour') end end -end \ No newline at end of file +end From 0bdad197aa3c60633df56a6c50bf5a03d21b0ada Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Tue, 3 Jan 2023 21:16:13 +0100 Subject: [PATCH 142/968] Apply consistently between sessions_controller and saml version --- .../users/saml_sessions_controller.rb | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/app/controllers/users/saml_sessions_controller.rb b/app/controllers/users/saml_sessions_controller.rb index ef22a4b13..bb6f2a8e0 100644 --- a/app/controllers/users/saml_sessions_controller.rb +++ b/app/controllers/users/saml_sessions_controller.rb @@ -15,21 +15,26 @@ def create # Enforce 2fa if enabled for SSO users if SiteSetting['Enable2FAForSsoUsers'] && user.present? && user.enabled_2fa - sign_out user - case user.two_factor_method - when 'app' - id = user.id - Users::SessionsController.first_factor << id - redirect_to login_verify_2fa_path(uid: id) - return - when 'email' - TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now - flash[:notice] = nil - flash[:info] = 'Please check your email inbox for a link to sign in.' - redirect_to root_path - return - end + handle_2fa_login(user) + return end end end + + private + + def handle_2fa_login(user) + sign_out user + case user.two_factor_method + when 'app' + id = user.id + Users::SessionsController.first_factor << id + redirect_to login_verify_2fa_path(uid: id) + when 'email' + TwoFactorMailer.with(user: user, host: request.hostname).login_email.deliver_now + flash[:notice] = nil + flash[:info] = 'Please check your email inbox for a link to sign in.' + redirect_to root_path + end + end end From bb1c51e0c47efa8a85b59c71d4a8bfb514f76409 Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Tue, 3 Jan 2023 23:38:45 +0100 Subject: [PATCH 143/968] Fix comparisons --- app/assets/javascripts/tags.js | 6 +++--- db/seeds/tag_sets.yml | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index a2d71685f..8169a5a6d 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -38,8 +38,8 @@ $(() => { data: function (params) { $this = $(this); // (for the tour) - if ($this.data('tag-set') === '-1') { - return Object.assign(params, { tag_set: "1" }); + if ($this.data('tag-set') === -1) { + return Object.assign(params, { tag_set: '1' }); } return Object.assign(params, { tag_set: $this.data('tag-set') }); }, @@ -47,7 +47,7 @@ $(() => { delay: 100, processResults: data => { // (for the tour) - if ($this.data('tag-set') === '-1') { + if ($this.data('tag-set') === -1) { return { results: [ { id: 1, text: 'hot-red-firebreather', desc: 'Very cute dragon' }, diff --git a/db/seeds/tag_sets.yml b/db/seeds/tag_sets.yml index 9dfe81cf2..8320d8bf5 100644 --- a/db/seeds/tag_sets.yml +++ b/db/seeds/tag_sets.yml @@ -1,3 +1,2 @@ - name: Main - name: Meta -- name: Tour \ No newline at end of file From 10e98598e33ad6c5d12ee2a7e06eedc7689ae14e Mon Sep 17 00:00:00 2001 From: Taico Aerts Date: Tue, 3 Jan 2023 23:39:22 +0100 Subject: [PATCH 144/968] Remove unnecessary test --- test/seeds/tour_test.rb | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 test/seeds/tour_test.rb diff --git a/test/seeds/tour_test.rb b/test/seeds/tour_test.rb deleted file mode 100644 index 4ae732368..000000000 --- a/test/seeds/tour_test.rb +++ /dev/null @@ -1,16 +0,0 @@ -require 'test_helper' - -class TourTest < ActiveSupport::TestCase - test "Tour question tag set exists for all communities after seeding" do - # Ensure there are multiple communities - c1 = Community.create(name: 'Test 1', host: 'test.host.1') - c2 = Community.create(name: 'Test 2', host: 'test.host.2') - - Rails.application.load_seed - - # Every community should have the Tour tagset - Community.all.each do |c| - assert_not_nil TagSet.unscoped.where(community: c).find_by('Tour') - end - end -end \ No newline at end of file From b3bc0d428e6dcf6b940157f98296a0734693b57a Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 9 Jan 2023 23:32:05 +0000 Subject: [PATCH 145/968] Convert 'else if' to 'if' because it depends on data from the previous 'if' block --- app/assets/javascripts/qpixel_api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index df4236002..77317537c 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -186,7 +186,7 @@ window.QPixel = { this._preferences = null; } } - else if (this._preferences == null) { + if (this._preferences == null) { // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't // loaded them for yet. Load from Redis via AJAX. const resp = await fetch('/users/me/preferences', { From 8be131c6cb77034ba0bcc628fcdfc0aa480fb92b Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 9 Jan 2023 23:38:04 +0000 Subject: [PATCH 146/968] Remove check for old preferences schema from over 2 years ago --- app/assets/javascripts/qpixel_api.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 77317537c..651d62a35 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -179,12 +179,6 @@ window.QPixel = { preferences: async () => { if (this._preferences == null && !!localStorage['qpixel.user_preferences']) { this._preferences = JSON.parse(localStorage['qpixel.user_preferences']); - - // If we don't have the global key, we're probably using an old preferences schema. - if (!this._preferences.global) { - delete localStorage['qpixel.user_preferences']; - this._preferences = null; - } } if (this._preferences == null) { // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't From 4ba7d6a67003bbd65f448e7b7ee18ec5307e423c Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 9 Jan 2023 23:44:50 +0000 Subject: [PATCH 147/968] Add early return for most frequent case to reduce processing --- app/assets/javascripts/qpixel_api.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 651d62a35..c0c3d1fbb 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -177,6 +177,10 @@ window.QPixel = { * @returns {Promise} a JSON object containing user preferences */ preferences: async () => { + // Early return for the most frequent case + if (this._preferences != null) { + return this._preferences; + } if (this._preferences == null && !!localStorage['qpixel.user_preferences']) { this._preferences = JSON.parse(localStorage['qpixel.user_preferences']); } From aab2313947760dd50feee4a35d8ade543b8e9928 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 9 Jan 2023 23:50:26 +0000 Subject: [PATCH 148/968] Remove null check that is now redundant due to early return --- app/assets/javascripts/qpixel_api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index c0c3d1fbb..e9b2056e6 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -181,7 +181,7 @@ window.QPixel = { if (this._preferences != null) { return this._preferences; } - if (this._preferences == null && !!localStorage['qpixel.user_preferences']) { + if (!!localStorage['qpixel.user_preferences']) { this._preferences = JSON.parse(localStorage['qpixel.user_preferences']); } if (this._preferences == null) { From c0b42f4cdf756f30189c975d4a777300215ab15a Mon Sep 17 00:00:00 2001 From: trichoplax Date: Tue, 10 Jan 2023 00:58:22 +0000 Subject: [PATCH 149/968] Improve comment to state local variable contains preferences --- app/assets/javascripts/qpixel_api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index e9b2056e6..ac10c01ea 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -177,7 +177,7 @@ window.QPixel = { * @returns {Promise} a JSON object containing user preferences */ preferences: async () => { - // Early return for the most frequent case + // Early return for the most frequent case (local variable already contains the preferences) if (this._preferences != null) { return this._preferences; } From 0b5b4b50a8af2d0f93cab1395287758058b163c0 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Tue, 10 Jan 2023 01:07:41 +0000 Subject: [PATCH 150/968] Reject only null & undefined, rather than all falsy local preferences --- app/assets/javascripts/qpixel_api.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index ac10c01ea..c4c5d9a3e 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -181,8 +181,13 @@ window.QPixel = { if (this._preferences != null) { return this._preferences; } - if (!!localStorage['qpixel.user_preferences']) { - this._preferences = JSON.parse(localStorage['qpixel.user_preferences']); + // Early return the preferences from localStorage unless null or undefined + const localStoragePreferences = ('qpixel.user_preferences' in localStorage) + ? JSON.parse(localStorage['qpixel.user_preferences']) + : null; + if (localStoragePreferences != null) { + this._preferences = localStoragePreferences; + return this._preferences; } if (this._preferences == null) { // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't From 4949c7fd212af3342d6b533fdaf1df24f8965d18 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Tue, 10 Jan 2023 01:21:13 +0000 Subject: [PATCH 151/968] Remove 'if' block made redundant by early returns --- app/assets/javascripts/qpixel_api.js | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index c4c5d9a3e..20f3e39fd 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -189,19 +189,17 @@ window.QPixel = { this._preferences = localStoragePreferences; return this._preferences; } - if (this._preferences == null) { - // If they're still null (or undefined) after loading from localStorage, we're probably on a site we haven't - // loaded them for yet. Load from Redis via AJAX. - const resp = await fetch('/users/me/preferences', { - credentials: 'include', - headers: { - 'Accept': 'application/json' - } - }); - const data = await resp.json(); - localStorage['qpixel.user_preferences'] = JSON.stringify(data); - this._preferences = data; - } + // Preferences are still null (or undefined) after loading from localStorage, so we're probably on a site we + // haven't loaded them for yet. Load from Redis via AJAX. + const resp = await fetch('/users/me/preferences', { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + const data = await resp.json(); + localStorage['qpixel.user_preferences'] = JSON.stringify(data); + this._preferences = data; return this._preferences; }, From a1bb30418d59b27ef8097ceb99efb3b088c4d885 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Tue, 10 Jan 2023 17:40:23 +0000 Subject: [PATCH 152/968] Store preferences on QPixel rather than window ('this') --- app/assets/javascripts/qpixel_api.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 20f3e39fd..c99af586d 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -178,16 +178,16 @@ window.QPixel = { */ preferences: async () => { // Early return for the most frequent case (local variable already contains the preferences) - if (this._preferences != null) { - return this._preferences; + if (QPixel._preferences != null) { + return QPixel._preferences; } // Early return the preferences from localStorage unless null or undefined const localStoragePreferences = ('qpixel.user_preferences' in localStorage) ? JSON.parse(localStorage['qpixel.user_preferences']) : null; if (localStoragePreferences != null) { - this._preferences = localStoragePreferences; - return this._preferences; + QPixel._preferences = localStoragePreferences; + return QPixel._preferences; } // Preferences are still null (or undefined) after loading from localStorage, so we're probably on a site we // haven't loaded them for yet. Load from Redis via AJAX. @@ -199,8 +199,8 @@ window.QPixel = { }); const data = await resp.json(); localStorage['qpixel.user_preferences'] = JSON.stringify(data); - this._preferences = data; - return this._preferences; + QPixel._preferences = data; + return QPixel._preferences; }, /** @@ -224,7 +224,7 @@ window.QPixel = { }); const data = await resp.json(); localStorage['qpixel.user_preferences'] = JSON.stringify(data); - this._preferences = data; + QPixel._preferences = data; prefs = await QPixel.preferences(); value = community ? prefs.community[name] : prefs.global[name]; @@ -259,8 +259,8 @@ window.QPixel = { console.error(resp); } else { - this._preferences = data.preferences; - localStorage['qpixel.user_preferences'] = JSON.stringify(this._preferences); + QPixel._preferences = data.preferences; + localStorage['qpixel.user_preferences'] = JSON.stringify(QPixel._preferences); } }, From 94ba47e3d066ed963406a8c8b7bbad214cd82a77 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Tue, 10 Jan 2023 23:22:35 +0000 Subject: [PATCH 153/968] Suppress preferences retrieval when user is not signed in --- app/assets/javascripts/qpixel_api.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index c99af586d..2e1e7882d 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -177,6 +177,11 @@ window.QPixel = { * @returns {Promise} a JSON object containing user preferences */ preferences: async () => { + const user = await QPixel.user(); + // Do not attempt to access preferences if user is not signed in + if ('error' in user) { + return null; + } // Early return for the most frequent case (local variable already contains the preferences) if (QPixel._preferences != null) { return QPixel._preferences; From 33abac3eebbf9c1035638272ea131158758aff19 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Wed, 11 Jan 2023 00:15:59 +0000 Subject: [PATCH 154/968] Extract duplicated localStorage preferences code into a function --- app/assets/javascripts/qpixel_api.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 2e1e7882d..95f2a3f64 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -203,8 +203,7 @@ window.QPixel = { } }); const data = await resp.json(); - localStorage['qpixel.user_preferences'] = JSON.stringify(data); - QPixel._preferences = data; + updatePreferencesLocally(data); return QPixel._preferences; }, @@ -228,8 +227,7 @@ window.QPixel = { } }); const data = await resp.json(); - localStorage['qpixel.user_preferences'] = JSON.stringify(data); - QPixel._preferences = data; + updatePreferencesLocally(data); prefs = await QPixel.preferences(); value = community ? prefs.community[name] : prefs.global[name]; @@ -264,11 +262,15 @@ window.QPixel = { console.error(resp); } else { - QPixel._preferences = data.preferences; - localStorage['qpixel.user_preferences'] = JSON.stringify(QPixel._preferences); + updatePreferencesLocally(data.preferences); } }, + updatePreferencesLocally: data => { + QPixel._preferences = data; + localStorage['qpixel.user_preferences'] = JSON.stringify(QPixel._preferences); + } + /** * Get the word in a string that the given position is in, and the position within that word. * @param splat an array, containing the string already split by however you define a "word" From 306a5541d11629e7d021af2d6b203e121872a963 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Wed, 11 Jan 2023 00:36:56 +0000 Subject: [PATCH 155/968] Use unique localStorage key per user to avoid corruption --- app/assets/javascripts/qpixel_api.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 95f2a3f64..5333ac4f9 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -171,6 +171,11 @@ window.QPixel = { _preferences: null, + preferencesLocalStorageKey: async () => { + const user = await QPixel.user(); + return `qpixel.user_${user.id}_preferences`; + } + /** * Get an object containing the current user's preferences. Loads, in order of precedence, from local variable, * localStorage, or Redis via AJAX. @@ -187,8 +192,9 @@ window.QPixel = { return QPixel._preferences; } // Early return the preferences from localStorage unless null or undefined - const localStoragePreferences = ('qpixel.user_preferences' in localStorage) - ? JSON.parse(localStorage['qpixel.user_preferences']) + const key = await preferencesLocalStorageKey(); + const localStoragePreferences = (key in localStorage) + ? JSON.parse(localStorage[key]) : null; if (localStoragePreferences != null) { QPixel._preferences = localStoragePreferences; @@ -268,7 +274,8 @@ window.QPixel = { updatePreferencesLocally: data => { QPixel._preferences = data; - localStorage['qpixel.user_preferences'] = JSON.stringify(QPixel._preferences); + const key = await preferencesLocalStorageKey(); + localStorage[key] = JSON.stringify(QPixel._preferences); } /** From 1bba8a3756072347aed116ed28c6d8632574cb9e Mon Sep 17 00:00:00 2001 From: trichoplax Date: Wed, 11 Jan 2023 00:43:08 +0000 Subject: [PATCH 156/968] Make 'if' explicit and include mandatory braces for code standards --- app/assets/javascripts/qpixel_api.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 5333ac4f9..db9bc4448 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -158,7 +158,9 @@ window.QPixel = { * @returns {Promise} a JSON object containing user details */ user: async () => { - if (QPixel._user) return QPixel._user; + if (QPixel._user != null) { + return QPixel._user; + } const resp = await fetch('/users/me', { credentials: 'include', headers: { From 81caadb0734dd73048fb84c505cba7acf9905dbd Mon Sep 17 00:00:00 2001 From: trichoplax Date: Wed, 11 Jan 2023 02:49:30 +0000 Subject: [PATCH 157/968] Add the commas I forgot between new functions in QPixel object --- app/assets/javascripts/qpixel_api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index db9bc4448..679284e1f 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -176,7 +176,7 @@ window.QPixel = { preferencesLocalStorageKey: async () => { const user = await QPixel.user(); return `qpixel.user_${user.id}_preferences`; - } + }, /** * Get an object containing the current user's preferences. Loads, in order of precedence, from local variable, @@ -278,7 +278,7 @@ window.QPixel = { QPixel._preferences = data; const key = await preferencesLocalStorageKey(); localStorage[key] = JSON.stringify(QPixel._preferences); - } + }, /** * Get the word in a string that the given position is in, and the position within that word. From 6a4b1476614b8b0b069c84f6dbcf3b117b22de39 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Wed, 11 Jan 2023 03:00:34 +0000 Subject: [PATCH 158/968] Change preference function to use early return --- app/assets/javascripts/qpixel_api.js | 32 +++++++++++++--------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 679284e1f..78c770f16 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -225,25 +225,23 @@ window.QPixel = { let prefs = await QPixel.preferences(); let value = community ? prefs.community[name] : prefs.global[name]; - // Deliberate === here: null is a valid value for a preference, but undefined means we haven't fetched it. - // If we haven't fetched a preference, that probably means it's new - run a full re-fetch. - if (value === undefined) { - const resp = await fetch('/users/me/preferences', { - credentials: 'include', - headers: { - 'Accept': 'application/json' - } - }); - const data = await resp.json(); - updatePreferencesLocally(data); - - prefs = await QPixel.preferences(); - value = community ? prefs.community[name] : prefs.global[name]; - return value; - } - else { + // Note that null is a valid value for a preference, but undefined means we haven't fetched it. + if (typeof(value) !== 'undefined') { return value; } + // If we haven't fetched a preference, that probably means it's new - run a full re-fetch. + const resp = await fetch('/users/me/preferences', { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + const data = await resp.json(); + updatePreferencesLocally(data); + + prefs = await QPixel.preferences(); + value = community ? prefs.community[name] : prefs.global[name]; + return value; }, /** From eba672d79f450dd38d7f63b8c5ecbb4df96dbb34 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Thu, 12 Jan 2023 05:03:15 +0000 Subject: [PATCH 159/968] Extract common code into separate functions --- app/assets/javascripts/qpixel_api.js | 49 +++++++++++++--------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 78c770f16..4bf0f7079 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -173,19 +173,14 @@ window.QPixel = { _preferences: null, - preferencesLocalStorageKey: async () => { - const user = await QPixel.user(); - return `qpixel.user_${user.id}_preferences`; - }, - /** * Get an object containing the current user's preferences. Loads, in order of precedence, from local variable, * localStorage, or Redis via AJAX. * @returns {Promise} a JSON object containing user preferences */ preferences: async () => { - const user = await QPixel.user(); // Do not attempt to access preferences if user is not signed in + const user = await QPixel.user(); if ('error' in user) { return null; } @@ -194,7 +189,7 @@ window.QPixel = { return QPixel._preferences; } // Early return the preferences from localStorage unless null or undefined - const key = await preferencesLocalStorageKey(); + const key = await QPixel._preferencesLocalStorageKey(); const localStoragePreferences = (key in localStorage) ? JSON.parse(localStorage[key]) : null; @@ -204,14 +199,7 @@ window.QPixel = { } // Preferences are still null (or undefined) after loading from localStorage, so we're probably on a site we // haven't loaded them for yet. Load from Redis via AJAX. - const resp = await fetch('/users/me/preferences', { - credentials: 'include', - headers: { - 'Accept': 'application/json' - } - }); - const data = await resp.json(); - updatePreferencesLocally(data); + await QPixel._fetchPreferences(); return QPixel._preferences; }, @@ -230,14 +218,7 @@ window.QPixel = { return value; } // If we haven't fetched a preference, that probably means it's new - run a full re-fetch. - const resp = await fetch('/users/me/preferences', { - credentials: 'include', - headers: { - 'Accept': 'application/json' - } - }); - const data = await resp.json(); - updatePreferencesLocally(data); + await QPixel._fetchPreferences(); prefs = await QPixel.preferences(); value = community ? prefs.community[name] : prefs.global[name]; @@ -268,13 +249,29 @@ window.QPixel = { console.error(resp); } else { - updatePreferencesLocally(data.preferences); + await QPixel._updatePreferencesLocally(data.preferences); } }, - updatePreferencesLocally: data => { + _preferencesLocalStorageKey: async () => { + const user = await QPixel.user(); + return `qpixel.user_${user.id}_preferences`; + }, + + _fetchPreferences: async () => { + const resp = await fetch('/users/me/preferences', { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + const data = await resp.json(); + await QPixel._updatePreferencesLocally(data); + }, + + _updatePreferencesLocally: async data => { QPixel._preferences = data; - const key = await preferencesLocalStorageKey(); + const key = await QPixel._preferencesLocalStorageKey(); localStorage[key] = JSON.stringify(QPixel._preferences); }, From 4582d436f632785ef73f0971fcafb98499544067 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Thu, 12 Jan 2023 05:44:41 +0000 Subject: [PATCH 160/968] Add JavaScript documentation comments for new functions --- app/assets/javascripts/qpixel_api.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index 4bf0f7079..814f929e5 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -253,11 +253,19 @@ window.QPixel = { } }, + /** + * Get the key to use for storing user preferences in localStorage, to avoid conflating users + * @returns {Promise} the localStorage key + */ _preferencesLocalStorageKey: async () => { const user = await QPixel.user(); return `qpixel.user_${user.id}_preferences`; }, + /** + * Update local variable _preferences and localStorage with an AJAX call for the user preferences + * @returns {Promise} + */ _fetchPreferences: async () => { const resp = await fetch('/users/me/preferences', { credentials: 'include', @@ -269,6 +277,11 @@ window.QPixel = { await QPixel._updatePreferencesLocally(data); }, + /** + * Set local variable _preferences and localStorage to new preferences data + * @param data an object, containing the new preferences data + * @returns {Promise} + */ _updatePreferencesLocally: async data => { QPixel._preferences = data; const key = await QPixel._preferencesLocalStorageKey(); From db8ceeb62351d5f1b46d53768b13067815a25d55 Mon Sep 17 00:00:00 2001 From: Monica Cellio Date: Fri, 13 Jan 2023 11:43:57 -0500 Subject: [PATCH 161/968] mention ruby 3 in installation instructions --- INSTALLATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index d460b444a..d21356797 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -43,7 +43,7 @@ brew install mysql bison openssl mysql-client bundle config --global build.mysql2 --with-opt-dir="$(brew --prefix openssl)" ``` -QPixel requires Ruby 2.7+. +QPixel requires Ruby 2.7+ and is tested with Ruby 3. ### Install JS runtime From bd8c8c2257d4a011a1fd5a38b2bac77ebe517fa8 Mon Sep 17 00:00:00 2001 From: Monica Cellio Date: Fri, 13 Jan 2023 11:50:48 -0500 Subject: [PATCH 162/968] update Ruby version info (since we've moved to Ruby 3 I don't know if we still support 2.7, so hedging a little there) --- INSTALLATION.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index d21356797..5b44e94b1 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -43,7 +43,8 @@ brew install mysql bison openssl mysql-client bundle config --global build.mysql2 --with-opt-dir="$(brew --prefix openssl)" ``` -QPixel requires Ruby 2.7+ and is tested with Ruby 3. +QPixel is tested with Ruby 3 (and works with Ruby 2.7 as of December 2022). + ### Install JS runtime From fa78b67b16b87ffa96c2cb89e4ae2f69f5f723c2 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 16 Jan 2023 11:51:18 +0000 Subject: [PATCH 163/968] Fix old internal link that no longer needs a number --- CODE-STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md index ad30362a0..c3b4a7b67 100644 --- a/CODE-STANDARDS.md +++ b/CODE-STANDARDS.md @@ -521,7 +521,7 @@ this.dataset = (Object.keys(data).length > 0) ? data : {}; Note the use of parentheses around the conditional expression - it makes it more obvious at first glance that this is a conditional statement. **This is a requirement.** -For very long or deeply indented expressions that exceed the 120-char line length limit ([item 8](#8-line-length)), +For very long or deeply indented expressions that exceed the [120-char line length limit](line-length), use the following line-break and indenting style: ```js From 68b5535858b981bb6611958c6a82050d7e2b1cc9 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 16 Jan 2023 11:58:06 +0000 Subject: [PATCH 164/968] Add missing backtick that was hiding a heading from rendered markdown --- CODE-STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md index c3b4a7b67..5bc45f49c 100644 --- a/CODE-STANDARDS.md +++ b/CODE-STANDARDS.md @@ -392,7 +392,7 @@ When adding an ID or class to reference an element from JavaScript, prefix the v - Make use of ``/`srcset` where possible. - Load images asynchronously where possible. -#### ` +#### `` - Ensure all pages have a level 1 header (`

`) that is not the website name. - Pages MUST NOT have more than one `

` element. - Use headings in order; style via CSS rather than using a smaller heading level. From e5e82f293b3d2eb3bb5c84e7c0b34b7a4e9479e1 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 16 Jan 2023 12:08:14 +0000 Subject: [PATCH 165/968] Replace abandoned heading number with heading wording --- CODE-STANDARDS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md index 5bc45f49c..ffc869fdf 100644 --- a/CODE-STANDARDS.md +++ b/CODE-STANDARDS.md @@ -130,9 +130,9 @@ for an example of all of the above. - Do not write more than one statement per line. ### Line breaks -Rules should be separated by a blank line, except for the two special cases provided in item -[**#3**](#Order-of-selectors) - namely, an extra blank line is expected between universal selectors and other -selectors, as well as before nested _at-rules_. As such, these rule groups should be separated by *two* spaces. +Rules should be separated by a blank line, except for the two special cases provided in +[Order of selectors](#order-of-selectors) - namely, an extra blank line is expected between universal selectors and +other selectors, as well as before nested _at-rules_. As such, these rule groups should be separated by *two* spaces. All properties are written on their own line and end with a semicolon. The closing bracket must appear in its own line. From 215f52dea3cbe5fc2f3e7201cece5c53237a14f6 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 16 Jan 2023 12:26:01 +0000 Subject: [PATCH 166/968] Use correct path in link text to avoid confusion --- CODE-STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md index ffc869fdf..c4f62de93 100644 --- a/CODE-STANDARDS.md +++ b/CODE-STANDARDS.md @@ -121,7 +121,7 @@ Pseudo-classes and pseudo-element selectors should appear *after* the main selec `@media` and other nested [*at-rules*](https://developer.mozilla.org/en-US/docs/Web/CSS/At-rule) should be added to the end of the document, preceded by an extra blank line. -See [*landing-page/primary.css @1ca2f671*](https://github.com/codidact/landing-page/blob/1ca2f671/dist/assets/css/primary.css) +See [* landing-page/dist/assets/css/primary.css @1ca2f671*](https://github.com/codidact/landing-page/blob/1ca2f671/dist/assets/css/primary.css) for an example of all of the above. ### Spacing From d88d3bd1daa1594aa1e2ea59f4cbc601e98316e2 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 16 Jan 2023 12:47:19 +0000 Subject: [PATCH 167/968] Add missing word and wrap very long line --- CODE-STANDARDS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md index c4f62de93..92deea444 100644 --- a/CODE-STANDARDS.md +++ b/CODE-STANDARDS.md @@ -379,7 +379,10 @@ When adding an ID or class to reference an element from JavaScript, prefix the v #### `` - If using `target="_blank"` to open links in a new tab, also include `rel="noopener noreferrer"`. -- If a JS-enabled link is necessary (it normally shouldn't - see note below), prefer `href="#"` over `href="javascript:void(0)"` (and its equivalent `href="javascript:;"`). Please do combine this with `event.preventDefault()` in order to prevent unwanted scrolling and adding of pointless entries to the user's browsing history. +- If a JS-enabled link is necessary (it normally shouldn't be - see note below), prefer `href="#"` over +`href="javascript:void(0)"` (and its equivalent `href="javascript:;"`). Please do combine this with +`event.preventDefault()` in order to prevent unwanted scrolling and adding of pointless entries to the user's browsing +history. **Note:** Since the above directive still requires JavaScript to be enabled, the RECOMMENDED first-line approach is to either link to an actual page/resource that performs the same expected action, or use a From 902827165b659287fca3c70bce3c75532c577993 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 16 Jan 2023 12:48:27 +0000 Subject: [PATCH 168/968] Replace fake footnotes that unexpectedly linked off page --- CODE-STANDARDS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CODE-STANDARDS.md b/CODE-STANDARDS.md index 92deea444..8cdee0e5c 100644 --- a/CODE-STANDARDS.md +++ b/CODE-STANDARDS.md @@ -388,7 +388,7 @@ history. approach is to either link to an actual page/resource that performs the same expected action, or use a `