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

Add acts-as-taggable-on gem for tagging functionality #41

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
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
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.14"

# Add tagging functionality
gem "acts-as-taggable-on"

gem "requestjs-rails"

group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
Expand Down
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0)
Expand Down Expand Up @@ -308,6 +311,8 @@ GEM
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
requestjs-rails (0.0.12)
railties (>= 6.1.0)
rexml (3.4.1)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
Expand Down Expand Up @@ -440,6 +445,7 @@ PLATFORMS

DEPENDENCIES
active_storage_validations
acts-as-taggable-on
annotaterb
bcrypt (~> 3.1.7)
bootsnap
Expand All @@ -461,6 +467,7 @@ DEPENDENCIES
puma (>= 5.0)
rails (~> 8.0.1)
rails-controller-testing
requestjs-rails
rspec-rails
rubocop-rails-omakase
selenium-webdriver
Expand Down
33 changes: 33 additions & 0 deletions app/controllers/concerns/taggable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module Taggable
extend ActiveSupport::Concern

private

def save_with_tags(record, params)
tag_list_param = params.slice!(:tag_list)

ActiveRecord::Base.transaction do
if record.update(params)
process_tags(record, tag_list_param[:tag_list])
true
else
false
end
end
end

private

def process_tags(record, tag_list)
return unless tag_list.present?

Rails.logger.info "Processing tags: #{tag_list} for record: #{record.id}"
tags = tag_list.compact_blank.join(",")

raise ArgumentError, "Invalid tags" unless valid_tags?(tags)
record.set_tag_list_on(record.language.code.to_sym, tags)
record.save!
end

def valid_tags?(tags) = true
end
18 changes: 18 additions & 0 deletions app/controllers/tags_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

class TagsController < ApplicationController
def index
@tags = ActsAsTaggableOn::Tag.for_context(language_tag_context)

render json: @tags
end

private

def tag_params
params.permit(:language_id)
end

def language_tag_context
Language.find(params[:language_id]).code.to_sym
end
end
25 changes: 21 additions & 4 deletions app/controllers/topics_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class TopicsController < ApplicationController
include Taggable

before_action :set_topic, only: [ :show, :edit, :update, :destroy, :archive ]

def index
Expand All @@ -14,7 +16,7 @@ def new
def create
@topic = scope.new(topic_params)

if @topic.save
if save_with_tags(@topic, topic_params)
redirect_to topics_path
else
render :new
Expand All @@ -28,8 +30,11 @@ def edit
end

def update
@topic.update(topic_params)
redirect_to topics_path
if save_with_tags(@topic, topic_params)
redirect_to topics_path
else
render :edit
end
end

def destroy
Expand All @@ -43,10 +48,22 @@ def archive
redirect_to topics_path
end

def tags
return [] unless params[:id].present? && topic_tags_params[:language_id].present?

set_topic
@tags = @topic.current_tags_for_language(topic_tags_params[:language_id])
render json: @tags
end

private

def topic_params
params.require(:topic).permit(:title, :description, :uid, :language_id, :provider_id, documents: [])
params.require(:topic).permit(:title, :description, :uid, :language_id, :provider_id, tag_list: [], documents: [])
end

def topic_tags_params
params.permit(:language_id)
end

helper_method :search_params
Expand Down
3 changes: 2 additions & 1 deletion app/javascript/application.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "@rails/request.js"
import "controllers"
104 changes: 104 additions & 0 deletions app/javascript/controllers/tags_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Controller } from "@hotwired/stimulus"
import { get } from "@rails/request.js"
import Tags from "bootstrap5-tags"

export default class extends Controller {
static targets = ["language", "tagList"]

connect() {
this.initializeTags()
}

/**
* Handle language change event and update tags accordingly
* @param {Event} event - Change event
*/
async changeLanguage(event) {
try {
const { resourceId, languageId } = this.getIds()

const tags = await this.fetchTags(languageId)
const selectedTags = await this.fetchAssignedTags(languageId, resourceId)

this.presentTags(tags, selectedTags)
} catch (error) {
console.error("Error changing language:", error)
}
}

/**
* Extract resource and language IDs from the form
* @returns {Object} Object containing resourceId and languageId
*/
getIds() {
return {
resourceId: this.languageTarget.dataset.resourceId,
languageId: this.languageTarget.value
}
}

/**
* Fetch available tags for a given language
* @param {string} languageId - ID of the selected language
* @returns {Object} Dictionary of tag names
*/
async fetchTags(languageId) {
try {
const response = await get(`/tags?language_id=${languageId}`, {
responseKind: "json"
})

if (!response.ok) return []

const json = await response.json
return Object.fromEntries(json.map(({name}) => [name, name]))
} catch (error) {
console.error("Error fetching tags:", error)
return []
}
}

/**
* Fetch tags already assigned to the resource
* @param {string} languageId - ID of the selected language
* @param {string} resourceId - ID of the current resource
* @returns {string} Comma-separated list of tag names
*/
async fetchAssignedTags(languageId, resourceId) {
if (resourceId === undefined) return ""

try {
const response = await get(`/topics/${resourceId}/tags?language_id=${languageId}`, {
responseKind: "json"
})

if (!response.ok) return ""

const json = await response.json
return json.map(x => x.name).join()
} catch (error) {
console.error("Error fetching assigned tags:", error)
return ""
}
}

/**
* Update the tags input with new tags and selections
* @param {Object} tags - Available tags
* @param {string} selectedTags - Previously selected tags
*/
presentTags(tags, selectedTags) {
this.initializeTags({
items: tags,
selected: selectedTags
}, true)
}

/**
* Initialize the tags input with given options
* @param {Object} options - Configuration options for bootstrap5-tags
*/
initializeTags(options = {}, reset = false) {
Tags.init("select#topic_tag_list", options, reset)
}
}
57 changes: 57 additions & 0 deletions app/models/concerns/localized_taggable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
module LocalizedTaggable
extend ActiveSupport::Concern

class LanguageContextError < StandardError; end

included do
acts_as_taggable_on :tags

after_initialize do
unless self.class.reflect_on_association(:language)
raise "#{self.class} must define belongs_to :language to include LanguageTaggable"
end
end
end

# Returns the language-specific tag context based on code
#
# @return [Symbol] the language context for tagging
# @raise [LanguageContextError] if language or code is not present
def language_tag_context
return nil if new_record?

raise LanguageContextError, "Language must be present" if language.nil?
raise LanguageContextError, "Language code must be present" if language.code.blank?

language.code.to_sym
end

# Retrieves all available tags for the current language context
#
# @return [ActiveRecord::Relation] collection of ActsAsTaggableOn::Tag
def available_tags
return [] if language_tag_context.nil?

ActsAsTaggableOn::Tag.for_context(language_tag_context)
end

# Retrieves associated tags for the current language context
#
# @return [Array<String>] list of tag names
def current_tags_list
return [] if language_tag_context.nil?

tag_list_on(language_tag_context)
end

# Retrieves associated tags for a specific language
# @param language_id [Integer] the ID of the language
# @return [ActiveRecord::Relation] collection of ActsAsTaggableOn::Tag
def current_tags_for_language(language_id)
return [] if language_id.nil?

language = Language.find(language_id)

tags_on(language.code.to_sym)
end
end
6 changes: 5 additions & 1 deletion app/models/language.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class Language < ApplicationRecord
def file_storage_prefix
return "" if name.downcase == "english" || name.nil?

"#{name.first(2).upcase}_"
"#{code.upcase}_"
end

def code
name.first(2).downcase
end
end
7 changes: 5 additions & 2 deletions app/models/topic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@
# index_topics_on_provider_id (provider_id)
#
class Topic < ApplicationRecord
STATES = %i[active archived].freeze

acts_as_taggable_on :tags

include Searcheable
include LocalizedTaggable

belongs_to :language
belongs_to :provider
Expand All @@ -29,8 +34,6 @@ class Topic < ApplicationRecord
validates :title, :language_id, :provider_id, presence: true
validates :documents, content_type: %w[image/jpeg image/png image/svg+xml image/webp image/avif image/gif video/mp4], size: { less_than: 10.megabytes }

STATES = %i[active archived].freeze

enum :state, STATES.map.with_index.to_h

scope :active, -> { where(state: :active) }
Expand Down
Loading