diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40983af7aa4a6345c1c68349d4a945127e8ea5e7..ac6324804b70580ef415f1298f0566b72f939d1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ request. Here they are: * There is a .rubocop.yml file provided in the project and rubocop is included in the bundle; please run `bundle exec rubocop` for Ruby style checking. -When writing CSS, keep in mind that our design framework, [Co-Design](https://design.codidact.org/) is available in Core, and +When writing CSS, keep in mind that our design framework, [Co-Design](https://design.codidact.org/) is available in QPixel, and should be used where possible. Avoid writing custom CSS if you can; favour using components and atomic classes from Co-Design. We also have some [guidelines for commit messages](https://github.com/codidact/core/wiki/Committing-guidelines). Again, please diff --git a/app/assets/javascripts/categories.js b/app/assets/javascripts/categories.js new file mode 100644 index 0000000000000000000000000000000000000000..3887efc3977492a4eb7f9bcfa1df018dfa33083d --- /dev/null +++ b/app/assets/javascripts/categories.js @@ -0,0 +1,45 @@ +$(() => { + $('.js-category-tag-set-select').on('change', ev => { + const $tgt = $(ev.target); + const tagSetId = $tgt.val(); + const formGroups = $('.js-category-tags-group'); + if (tagSetId) { + formGroups.each((i, el) => { + const $el = $(el); + const $caption = $el.find('.js-tags-group-caption'); + $caption.find('[data-state="absent"]').hide(); + $caption.find('[data-state="present"]').show(); + + $el.find('.js-tag-select').attr('data-tag-set', tagSetId).attr('disabled', false); + }); + } + else { + formGroups.each((i, el) => { + const $el = $(el); + const $caption = $el.find('.js-tags-group-caption'); + $caption.find('[data-state="absent"]').show(); + $caption.find('[data-state="present"]').hide(); + + $el.find('.js-tag-select').attr('data-tag-set', null).attr('disabled', true); + }); + } + }); + + $('.js-add-required-topic').on('click', ev => { + const $required = $('.js-required-tags'); + const $topic = $('.js-topic-tags'); + const union = ($required.val() || []).concat($topic.val() || []); + + const options = $topic.find('option').toArray(); + const optionIds = options.map(x => $(x).attr('value')); + const missing = union.filter(x => !optionIds.includes(x)); + const missingOptions = $required.find('option').toArray().filter(x => missing.includes($(x).attr('value'))); + + missingOptions.forEach(opt => { + const $append = $(opt).clone(); + $append.removeAttr('data-select2-id'); + $topic.append($append); + }); + $topic.val(union).trigger('change'); + }); +}); \ No newline at end of file diff --git a/app/assets/javascripts/tags.js b/app/assets/javascripts/tags.js index 1439c371fbb44404ba78077aad700a62e59ba74e..be3e3af18223b017acd217b90193bfc1f3d3f95b 100644 --- a/app/assets/javascripts/tags.js +++ b/app/assets/javascripts/tags.js @@ -1,14 +1,18 @@ $(() => { - $('.js-tag-select').select2({ - tags: true, - ajax: { - url: '/tags', - data: function (params) { - return Object.assign(params, { tag_set: $(this).data('tag-set') }); - }, - headers: { 'Accept': 'application/json' }, - delay: 100, - processResults: data => ({results: data.map(t => ({id: t.name, text: t.name}))}), - } + $('.js-tag-select').each((i, el) => { + const $tgt = $(el); + const useIds = $tgt.attr('data-use-ids') === 'true'; + $tgt.select2({ + tags: $tgt.attr('data-create') !== 'false', + ajax: { + url: '/tags', + data: function (params) { + return Object.assign(params, { tag_set: $(this).data('tag-set') }); + }, + headers: { 'Accept': 'application/json' }, + delay: 100, + processResults: data => ({results: data.map(t => ({id: useIds ? t.id : t.name, text: t.name}))}), + } + }); }); }); \ No newline at end of file diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index bf202126f14a5060c76783cea4527df48f7171bf..6f13bce9914553465d9313f92ab266475a996dfe 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -83,4 +83,8 @@ pre.unformatted { .stat-value { font-size: 2.0em; +} + +.badge.is-tag.is-outlined { + border: 1px solid #001db1; } \ No newline at end of file diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index e1bd54c0ffea160de3bbed723f6d166219bfc664..62b0a6329f515529a6d3bcfeae4523c016b96516 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -75,7 +75,7 @@ class CategoriesController < ApplicationController def category_params params.require(:category).permit(:name, :short_wiki, :tag_set_id, :is_homepage, :min_trust_level, :button_text, :color_code, :min_view_trust_level, :license_id, :sequence, display_post_types: [], - post_type_ids: []) + post_type_ids: [], required_tag_ids: [], topic_tag_ids: []) end def verify_view_access diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb index 916e3f00e52b3ef771423975bc599364334bc384..f68b28e444c53d324905811c023eb8894071dcdd 100644 --- a/app/controllers/questions_controller.rb +++ b/app/controllers/questions_controller.rb @@ -49,7 +49,7 @@ class QuestionsController < ApplicationController not_found return end - @questions = @tag.posts.undeleted.order('updated_at DESC').paginate(page: params[:page], per_page: 50) + @questions = @tag.posts.list_includes.undeleted.order('updated_at DESC').paginate(page: params[:page], per_page: 50) end def lottery diff --git a/app/models/application_record.rb b/app/models/application_record.rb index e5c54e276baf834b15b95bcf6998364edd9f7441..2aa76e8e6c7c565c063c8dbdf83afe52ce42baef 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -30,6 +30,13 @@ class ApplicationRecord < ActiveRecord::Base ActiveRecord::Base.send(:sanitize_sql_array, ["MATCH (#{cols}) AGAINST (? IN BOOLEAN MODE)", term]) end + + def self.sanitize_sql_in(ary) + return "(NULL)" unless ary.present? && ary.respond_to?(:map) + + ary = ary.map { |el| ActiveRecord::Base.sanitize_sql_array(['?', el]) } + "(#{ary.join(', ')})" + end end module UserSortable diff --git a/app/models/category.rb b/app/models/category.rb index 486fcb87cdb1eff8302af486e14df3391e433031..cba36d5c375277c0d5167594751f06bb4cb98096 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -2,6 +2,8 @@ class Category < ApplicationRecord include CommunityRelated has_and_belongs_to_many :post_types + has_and_belongs_to_many :required_tags, class_name: 'Tag', join_table: 'categories_required_tags' + has_and_belongs_to_many :topic_tags, class_name: 'Tag', join_table: 'categories_topic_tags' has_many :posts belongs_to :tag_set belongs_to :license diff --git a/app/models/post.rb b/app/models/post.rb index bd3ab8060f1bc2630db840e86b250fc8b1d8caac..6d5033ba2f8a74bdb710ff9758db19db5bfb938f 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -30,17 +30,18 @@ class Post < ApplicationRecord validate :stripped_minimum, if: :question? validate :category_allows_post_type validate :license_available + validate :required_tags? scope :undeleted, -> { where(deleted: false) } scope :deleted, -> { where(deleted: true) } scope :qa_only, -> { where(post_type_id: [Question.post_type_id, Answer.post_type_id]) } - scope :list_includes, -> { includes(:user, user: :avatar_attachment) } + scope :list_includes, -> { includes(:user, :tags, user: :avatar_attachment) } after_save :check_attribution_notice after_save :modify_author_reputation after_save :copy_last_activity_to_parent after_save :break_description_cache - after_save :update_tag_associations, if: :question? + before_validation :update_tag_associations, if: :question? after_create :create_initial_revision after_create :add_license_if_nil @@ -230,4 +231,13 @@ class Post < ApplicationRecord update(license: License.site_default) end end + + def required_tags? + required = category&.required_tag_ids + return unless required.present? && !required.empty? + + unless tag_ids.any? { |t| required.include? t } + errors.add(:tags, "must contain at least one required tag (#{category.required_tags.pluck(:name).join(', ')})") + end + end end diff --git a/app/models/question.rb b/app/models/question.rb index 50b2989471c68ca8dc179752b5c1518198e3dc37..b0a347e7e0a0e11e29982e6d6f56a1ecb1cec626 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -11,15 +11,6 @@ class Question < Post PostType.mapping['Question'] end - validates :title, :body, :tags_cache, presence: true - validate :tags_in_tag_set - validate :maximum_tags - validate :maximum_tag_length - validate :no_spaces_in_tags - validate :stripped_minimum - - after_save :update_tag_associations - def answers Answer.where(parent: self) end diff --git a/app/models/tag.rb b/app/models/tag.rb index 65024e43b4894060fdb8b705445547c91a27a145..cd89a7d0d35a75be50baf01908b569ee4031c212 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,6 +1,13 @@ class Tag < ApplicationRecord include CommunityRelated + scope :category_order, -> (required_ids, topic_ids) do + helpers = ActionController::Base.helpers + order(Arel.sql("id IN #{sanitize_sql_in(required_ids)} DESC"), + Arel.sql("id IN #{sanitize_sql_in(topic_ids)} DESC"), + name: :asc) + end + has_and_belongs_to_many :posts belongs_to :tag_set diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 9613e9d0cbf9cc64c572d419ba5182430c52640f..7a5465b726ad69ebd892af6d0737bb5da38695f6 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -31,7 +31,7 @@ <%= f.label :tag_set_id, 'Tag set', class: 'form-element' %> <span class="form-caption">Which tag set may posts in this category draw from?</span> <%= f.select :tag_set_id, options_for_select(TagSet.all.map { |ts| [ts.name, ts.id] }, selected: @category.tag_set_id), - { include_blank: true }, class: 'form-element' %> + { include_blank: true }, class: 'form-element js-category-tag-set-select' %> </div> <div class="form-group"> @@ -90,5 +90,40 @@ <%= f.number_field :sequence, class: 'form-element' %> </div> + <div class="form-group js-category-tags-group"> + <%= f.label :required_tag_ids, 'Required tags', class: 'form-element' %> + <span class="form-caption js-tags-group-caption"> + <span data-state="present" style="<%= @category.tag_set.nil? ? 'display: none' : '' %>"> + Required tags for this category - every post will be required to have one of these tags. + </span> + <span data-state="absent" style="<%= @category.tag_set.nil? ? '' : 'display: none' %>"> + Select a tag set first. + </span> + </span> + <% disabled = @category.tag_set.nil? %> + <%= f.select :required_tag_ids, options_for_select(@category.required_tags.map { |t| [t.name, t.id] }, + selected: @category.required_tag_ids), + { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-required-tags', + data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %> + </div> + + <div class="form-group js-category-tags-group"> + <%= f.label :required_tag_ids, 'Topic tags', class: 'form-element' %> + <span class="form-caption js-tags-group-caption"> + <span data-state="present" style="<%= @category.tag_set.nil? ? 'display: none' : '' %>"> + Tags that will be highlighted as the most important tag on a question. + <a href="javascript:void(0)" class="js-add-required-topic">Add all required tags</a> + </span> + <span data-state="absent" style="<%= @category.tag_set.nil? ? '' : 'display: none' %>"> + Select a tag set first. + </span> + </span> + + <%= f.select :topic_tag_ids, options_for_select(@category.topic_tags.map { |t| [t.name, t.id] }, + selected: @category.topic_tag_ids), + { include_blank: true }, multiple: true, class: 'form-element js-tag-select js-topic-tags', + data: { tag_set: @category.tag_set&.id, create: 'false', use_ids: 'true' }, disabled: disabled %> + </div> + <%= f.submit 'Save', class: 'button is-filled' %> <% end %> diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb index 1a71fceb5637603fd97bf8b0c9841e783531be7f..a2b45b8092f74a88507ba14246cd547d76f00547 100644 --- a/app/views/posts/_expanded.html.erb +++ b/app/views/posts/_expanded.html.erb @@ -98,9 +98,14 @@ <% if is_question %> <div class="post--tags has-padding-2"> <% tag_set = post.tag_set %> - <% post.tags_cache.each do |tag| %> - <% next if tag.nil? || tag.empty? %> - <%= link_to tag, questions_tagged_path(tag_set: tag_set.id, tag: tag), class: 'badge is-tag' %> + <% required_ids = post.category&.required_tag_ids %> + <% topic_ids = post.category&.topic_tag_ids %> + <% post.tags.category_order(required_ids, topic_ids).each do |tag| %> + <% next if tag.nil? %> + <% required = required_ids&.include? tag.id %> + <% topic = topic_ids&.include? tag.id %> + <%= link_to tag.name, questions_tagged_path(tag_set: tag_set.id, tag: tag.name), + class: "badge is-tag #{required ? 'is-filled' : ''} #{topic ? 'is-outlined' : ''}" %> <% end %> </div> <% end %> diff --git a/app/views/posts/_form.html.erb b/app/views/posts/_form.html.erb index aba24f038783fef453943a3f9ab1c252c5d0f49d..713d328527be91402ea475996f5732ab0c03a2d3 100644 --- a/app/views/posts/_form.html.erb +++ b/app/views/posts/_form.html.erb @@ -38,6 +38,15 @@ <div class="form-group"> <%= f.label :tags_cache, 'Tags (at least one):', class: 'form-element' %> + <% required_tags = @category.required_tags.to_a %> + <% unless required_tags.empty? %> + <span class="form-caption"> + Requires at least one of + <% required_tags.each do |tag| %> + <span class="badge is-tag is-filled"><%= tag.name %></span> + <% end %> + </span> + <% end %> <%= f.select :tags_cache, options_for_select(@post.tags_cache.map { |t| [t, t] }, selected: @post.tags_cache), { include_blank: true }, multiple: true, class: "form-element js-tag-select", data: { tag_set: @category.tag_set_id } %> diff --git a/app/views/posts/_list.html.erb b/app/views/posts/_list.html.erb index 290017e6d5d3165438d4cb1bc925515900cb08c2..5703b4e84e486b74448fecae7667d7701ab71633 100644 --- a/app/views/posts/_list.html.erb +++ b/app/views/posts/_list.html.erb @@ -27,8 +27,13 @@ <div class="has-padding-top-2"> <% if is_question %> <% tag_set = post.tag_set %> - <% post.tags_cache.each do |tag| %> - <%= link_to tag, questions_tagged_path(tag_set: tag_set.id, tag: tag), class: 'badge is-tag' %> + <% required_ids = post.category&.required_tag_ids %> + <% topic_ids = post.category&.topic_tag_ids %> + <% post.tags.category_order(required_ids, topic_ids).each do |tag| %> + <% required = required_ids&.include? tag.id %> + <% topic = topic_ids&.include? tag.id %> + <%= link_to tag.name, questions_tagged_path(tag_set: tag_set.id, tag: tag.name), + class: "badge is-tag #{required ? 'is-filled' : ''} #{topic ? 'is-outlined' : ''}" %> <% end %> <% end %> </div> diff --git a/db/migrate/20200508115752_add_category_tag_join_tables.rb b/db/migrate/20200508115752_add_category_tag_join_tables.rb new file mode 100644 index 0000000000000000000000000000000000000000..f79752d7c334bd55acda2dd6c834f5305deab2e7 --- /dev/null +++ b/db/migrate/20200508115752_add_category_tag_join_tables.rb @@ -0,0 +1,13 @@ +class AddCategoryTagJoinTables < ActiveRecord::Migration[5.2] + def change + create_table :categories_required_tags, id: false, primary_key: [:category_id, :tag_id] do |t| + t.bigint :category_id + t.bigint :tag_id + end + + create_table :categories_topic_tags, id: false, primary_key: [:category_id, :tag_id] do |t| + t.bigint :category_id + t.bigint :tag_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 915d9fbd6bca8789079b1948807197aa515f5eb7..ae7e6d69558fecd931424e957e98c2429ce2cc8e 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.define(version: 2020_05_08_112958) do +ActiveRecord::Schema.define(version: 2020_05_08_115752) do create_table "active_storage_attachments", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t| t.string "name", null: false @@ -61,6 +61,16 @@ ActiveRecord::Schema.define(version: 2020_05_08_112958) do t.bigint "post_type_id", null: false end + create_table "categories_required_tags", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "category_id" + t.bigint "tag_id" + end + + create_table "categories_topic_tags", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "category_id" + t.bigint "tag_id" + end + create_table "close_reasons", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t| t.string "name" t.text "description"