Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Category tags #74

Merged
merged 9 commits into from
May 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions app/assets/javascripts/categories.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
26 changes: 15 additions & 11 deletions app/assets/javascripts/tags.js
Original file line number Diff line number Diff line change
@@ -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}))}),
}
});
});
});
4 changes: 4 additions & 0 deletions app/assets/stylesheets/utilities.scss
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,8 @@ pre.unformatted {

.stat-value {
font-size: 2.0em;
}

.badge.is-tag.is-outlined {
border: 1px solid #001db1;
}
2 changes: 1 addition & 1 deletion app/controllers/categories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def set_category
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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/questions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def tagged
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
Expand Down
7 changes: 7 additions & 0 deletions app/models/application_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ def self.sanitize_for_search(term, **cols)

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
Expand Down
2 changes: 2 additions & 0 deletions app/models/category.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions app/models/post.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -230,4 +231,13 @@ def add_license_if_nil
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
9 changes: 0 additions & 9 deletions app/models/question.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,6 @@ def self.post_type_id
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
Expand Down
7 changes: 7 additions & 0 deletions app/models/tag.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
37 changes: 36 additions & 1 deletion app/views/categories/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down Expand Up @@ -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.
cellio marked this conversation as resolved.
Show resolved Hide resolved
<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 %>
11 changes: 8 additions & 3 deletions app/views/posts/_expanded.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
9 changes: 9 additions & 0 deletions app/views/posts/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 } %>
Expand Down
9 changes: 7 additions & 2 deletions app/views/posts/_list.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
13 changes: 13 additions & 0 deletions db/migrate/20200508115752_add_category_tag_join_tables.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +61,16 @@
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"
Expand Down