diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb new file mode 100644 index 00000000..fc2a438e --- /dev/null +++ b/app/controllers/api/v1/tags_controller.rb @@ -0,0 +1,32 @@ + +module Api + module V1 + class TagsController < ApplicationController + before_action :validate_language + + def index + @tags = ActsAsTaggableOn::Tag.for_context(language_tag_context) + + render json: @tags + end + + private + + def language_tag_context + Language.find(params[:language_id]).code.to_sym + end + + def validate_language + render_error unless language_id_param + end + + def language_id_param + params.require(:language_id) + end + + def render_error + render json: { error: "Language is required" }, status: :bad_request + end + end + end +end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index d65b0777..15a43a74 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,28 +1,60 @@ class TagsController < ApplicationController - before_action :validate_language + before_action :set_tag, only: [ :show, :edit, :update, :destroy ] def index - @tags = ActsAsTaggableOn::Tag.for_context(language_tag_context) + @tags = Tag.includes(:cognates, :reverse_cognates).references(:tag) + end - render json: @tags + def new + @tag = Tag.new end - private + def create + @tag = Tag.new(tag_params) + + if @tag.save + redirect_to tags_path + else + render :new, status: :unprocessable_entity + end + end + + def show + end + + def edit + end - def language_tag_context - Language.find(params[:language_id]).code.to_sym + def update + if @tag.update!(tag_params) + redirect_to tags_path, notice: "Tag was successfully updated." + else + render :edit, status: :unprocessable_entity + end end - def validate_language - render_error unless language_id_param + def destroy + redirect_to tags_path and return unless Current.user.is_admin? + + if params[:confirmed] + @tag.destroy + redirect_to tags_path, notice: "Tag was successfully destroyed." + else + @confirmation_required = @tag.taggings_count.positive? + respond_to do |format| + format.turbo_stream + end + end end - def language_id_param - params.require(:language_id) + private + + def tag_params + params.require(:tag).permit(:name, cognates_list: []) end - def render_error - render json: { error: "Language is required" }, status: :bad_request + def set_tag + @tag = Tag.find(params[:id]) end end diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index e8cf2756..51d6b2a9 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -105,7 +105,7 @@ def topic_tags_params def search_params return {} unless params[:search].present? - params.require(:search).permit(:query, :state, :provider_id, :language_id, :year, :month, :order) + params.require(:search).permit(:query, :state, :provider_id, :language_id, :year, :month, :order, tag_list: []) end helper_method :search_params diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be794..d956a996 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,9 @@ module ApplicationHelper + def flash_class(level) + case level + when "notice" then "alert-light-success" + when "alert" then "alert-light-danger" + else "alert-light-info" + end + end end diff --git a/app/javascript/controllers/select_tags_controller.js b/app/javascript/controllers/select_tags_controller.js index a978595b..246ae7e9 100644 --- a/app/javascript/controllers/select_tags_controller.js +++ b/app/javascript/controllers/select_tags_controller.js @@ -9,6 +9,10 @@ export default class extends Controller { this.initializeTags() } + notify() { + this.dispatch("notify", { detail: { content: Array.from(this.tagListTarget.selectedOptions).map(option => option.value) } }) + } + /** * Handle language change event and update tags accordingly * @param {Event} event - Change event @@ -44,7 +48,7 @@ export default class extends Controller { */ async fetchTags(languageId) { try { - const response = await get(`/tags?language_id=${languageId}`, { + const response = await get(`/api/v1/tags?language_id=${languageId}`, { responseKind: "json" }) @@ -99,6 +103,6 @@ export default class extends Controller { * @param {Object} options - Configuration options for bootstrap5-tags */ initializeTags(options = {}, reset = false) { - Tags.init("select#topic_tag_list", options, reset) + Tags.init(`select#${this.tagListTarget.id}`, options, reset) } } diff --git a/app/models/concerns/localized_taggable.rb b/app/models/concerns/localized_taggable.rb index 6b05e800..e27a01e9 100644 --- a/app/models/concerns/localized_taggable.rb +++ b/app/models/concerns/localized_taggable.rb @@ -35,6 +35,15 @@ def available_tags ActsAsTaggableOn::Tag.for_context(language_tag_context) end + # Retrieves associated tags for the current language context + # + # @return [Array] list of tags + def current_tags + return [] if language_tag_context.nil? + + tags_on(language_tag_context) + end + # Retrieves associated tags for the current language context # # @return [Array] list of tag names diff --git a/app/models/concerns/searcheable.rb b/app/models/concerns/searcheable.rb index c8b80159..21075bbe 100644 --- a/app/models/concerns/searcheable.rb +++ b/app/models/concerns/searcheable.rb @@ -28,6 +28,10 @@ def by_state(state) where(state: state) end + def by_tag_list(tag_list) + tagged_with(tag_list, any: true) + end + def sort_order(order_from_params) return :desc unless SORTS.include?(order_from_params) @@ -44,6 +48,7 @@ def sort_order(order_from_params) .then { |scope| params[:year].present? ? scope.by_year(params[:year]) : scope } .then { |scope| params[:month].present? ? scope.by_month(params[:month]) : scope } .then { |scope| params[:query].present? ? scope.search(params[:query]) : scope } + .then { |scope| params[:tag_list].present? ? scope.by_tag_list(params[:tag_list]) : scope } .then { |scope| scope.order(created_at: sort_order(params[:order].present? ? params[:order].to_sym : :desc)) } end end diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 00000000..157eb0d6 --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,63 @@ +class Tag < ActsAsTaggableOn::Tag + has_many :tag_cognates, dependent: :destroy + has_many :cognates, through: :tag_cognates + accepts_nested_attributes_for :tag_cognates, allow_destroy: true + + # Reverse relationship for cognates referencing this tag + has_many :reverse_tag_cognates, class_name: "TagCognate", foreign_key: :cognate_id + has_many :reverse_cognates, through: :reverse_tag_cognates, source: :tag + + # Returns a unique list of all cognate tags, including both direct and reverse relationships + # + # @return [Array] unique array of associated cognate tags + def cognates_tags + (cognates + reverse_cognates).uniq + end + + # Returns a list of all cognate tag names + # + # @return [Array] array of cognate tag names + def cognates_list + cognates.pluck(:name) + reverse_cognates.pluck(:name) + end + + # Sets cognate relationships based on a list of tag names + # + # @param str_list [Array] list of tag names to set as cognates + def cognates_list=(str_list) + if persisted? + remove_cognates(str_list) + create_cognates(str_list) + else + self.tag_cognates_attributes = str_list.filter_map do |name| + next if name.blank? + + cognate = Tag.find_or_create_with_like_by_name(name) + { cognate_id: cognate.id } if cognate.id != id + end + end + end + + # Returns tags that are available to be set as cognates + # + # @return [ActiveRecord::Relation] collection of Tag records excluding self + def all_available_tags + Tag.where.not(id: id) + end + + private + + def create_cognates(str_list) + str_list.filter_map do |name| + next if name.blank? + + Tag.find_or_create_with_like_by_name(name).then do |tag| + tag_cognates.create(cognate: tag) + end + end + end + + def remove_cognates(str_list) + tag_cognates.where.not(cognate_id: Tag.where(name: str_list).pluck(:id)).destroy_all + end +end diff --git a/app/models/tag_cognate.rb b/app/models/tag_cognate.rb new file mode 100644 index 00000000..34d76d19 --- /dev/null +++ b/app/models/tag_cognate.rb @@ -0,0 +1,34 @@ +# == Schema Information +# +# Table name: tag_cognates +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# cognate_id :bigint +# tag_id :bigint +# +# Indexes +# +# index_tag_cognates_on_cognate_id (cognate_id) +# index_tag_cognates_on_tag_id (tag_id) +# index_tag_cognates_on_tag_id_and_cognate_id (tag_id,cognate_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (cognate_id => tags.id) +# fk_rails_... (tag_id => tags.id) +# +class TagCognate < ApplicationRecord + belongs_to :tag, class_name: "Tag" + belongs_to :cognate, class_name: "Tag" + + validates :tag_id, uniqueness: { scope: :cognate_id } + validate :no_self_reference + + private + + def no_self_reference + errors.add(:base, "Tag can't be its own cognate") if tag_id == cognate_id + end +end diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index f7761865..813ead02 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -54,33 +54,39 @@ <% if Current.user.is_admin? %> - + - + - + + + - + <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index a61cf3d8..be6ff7b8 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -77,6 +77,8 @@ <%= render "layouts/sidebar" %>