From fe58cc54eef582243d490ffc1a16b45eda904c89 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Fri, 28 Mar 2025 09:18:17 +0100 Subject: [PATCH 01/18] refactor: automate tags select id --- app/javascript/controllers/select_tags_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/select_tags_controller.js b/app/javascript/controllers/select_tags_controller.js index a978595b..55215ec0 100644 --- a/app/javascript/controllers/select_tags_controller.js +++ b/app/javascript/controllers/select_tags_controller.js @@ -99,6 +99,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) } } From fbd7e28529001c247572e5137d4db37ae8245f02 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Sat, 29 Mar 2025 16:37:57 +0100 Subject: [PATCH 02/18] feat(tags): add tag_cognate model --- app/models/tag.rb | 20 ++++++++++++++ app/models/tag_cognate.rb | 27 +++++++++++++++++++ ...26183811_create_tag_cognates_join_table.rb | 11 ++++++++ db/schema.rb | 12 +++++++++ 4 files changed, 70 insertions(+) create mode 100644 app/models/tag.rb create mode 100644 app/models/tag_cognate.rb create mode 100644 db/migrate/20250326183811_create_tag_cognates_join_table.rb diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 00000000..1861abde --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,20 @@ +class Tag < ActsAsTaggableOn::Tag + has_many :tag_cognates + has_many :cognates, through: :tag_cognates + + # 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 + + def cognates_list + cognates.pluck(:name) + reverse_cognates.pluck(:name) + end + + def cognates_list=(cognates_list_str) + self.cognates = Tag.where(name: cognates_list_str) + end + + def available_cognates + Tag.all - [ cognates, self ] + end +end diff --git a/app/models/tag_cognate.rb b/app/models/tag_cognate.rb new file mode 100644 index 00000000..67756197 --- /dev/null +++ b/app/models/tag_cognate.rb @@ -0,0 +1,27 @@ +# == 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: "ActsAsTaggableOn::Tag" + belongs_to :cognate, class_name: "ActsAsTaggableOn::Tag" + + validates :tag_id, uniqueness: { scope: :cognate_id } +end diff --git a/db/migrate/20250326183811_create_tag_cognates_join_table.rb b/db/migrate/20250326183811_create_tag_cognates_join_table.rb new file mode 100644 index 00000000..4dc1522e --- /dev/null +++ b/db/migrate/20250326183811_create_tag_cognates_join_table.rb @@ -0,0 +1,11 @@ +class CreateTagCognatesJoinTable < ActiveRecord::Migration[8.0] + def change + create_table :tag_cognates do |t| + t.references :tag, foreign_key: true + t.references :cognate, foreign_key: { to_table: :tags } + t.timestamps + end + + add_index :tag_cognates, [ :tag_id, :cognate_id ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index cc94803f..6d21a792 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -90,6 +90,16 @@ t.index ["user_id"], name: "index_sessions_on_user_id" end + create_table "tag_cognates", force: :cascade do |t| + t.bigint "tag_id" + t.bigint "cognate_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["cognate_id"], name: "index_tag_cognates_on_cognate_id" + t.index ["tag_id", "cognate_id"], name: "index_tag_cognates_on_tag_id_and_cognate_id", unique: true + t.index ["tag_id"], name: "index_tag_cognates_on_tag_id" + end + create_table "taggings", force: :cascade do |t| t.bigint "tag_id" t.string "taggable_type" @@ -148,5 +158,7 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "sessions", "users" + add_foreign_key "tag_cognates", "tags" + add_foreign_key "tag_cognates", "tags", column: "cognate_id" add_foreign_key "taggings", "tags" end From 8556ee40901ccf322c967f80f056a6647456c55d Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Sat, 29 Mar 2025 16:15:41 +0100 Subject: [PATCH 03/18] dev(seeds): add tag cognates --- db/seeds.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/db/seeds.rb b/db/seeds.rb index 66164cbb..c9915b27 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -57,6 +57,7 @@ end puts "Topics created!" +puts "Tagging topics.." Topic.all.each do |topic| language_code = topic.language.code 3.times do @@ -65,7 +66,11 @@ end end -puts "Tags created!" +puts "Topics tagged!" +puts "Creating tags cognates..." +Tag.first.tag_cognates.create(cognate_id: Tag.second.id) + +puts "Topics tagged!" puts "Creating users..." User.create(email: "admin@mail.com", password: "test123", is_admin: true) From 3907d02d2c4f046b4dd31dde703d8fa02c91297b Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Thu, 27 Mar 2025 20:03:31 +0100 Subject: [PATCH 04/18] refactor: move tags controller into api namespace --- app/controllers/api/v1/tags_controller.rb | 32 +++++++++++++++++++ .../controllers/select_tags_controller.js | 2 +- config/routes.rb | 8 +++-- spec/requests/tags/{ => api/v1}/index_spec.rb | 6 ++-- 4 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 app/controllers/api/v1/tags_controller.rb rename spec/requests/tags/{ => api/v1}/index_spec.rb (83%) 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/javascript/controllers/select_tags_controller.js b/app/javascript/controllers/select_tags_controller.js index 55215ec0..dabe878c 100644 --- a/app/javascript/controllers/select_tags_controller.js +++ b/app/javascript/controllers/select_tags_controller.js @@ -44,7 +44,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" }) diff --git a/config/routes.rb b/config/routes.rb index 211e95f5..bbc842fd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,7 @@ resource :session resources :uploads, only: %i[create destroy] resources :users + resources :tags resources :topics do member do put :archive @@ -33,8 +34,11 @@ # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker # Defines the root path route ("/") - - resources :tags, only: %i[index] + namespace :api do + namespace :v1 do + resources :tags, only: %i[index show] + end + end root "home#index" end diff --git a/spec/requests/tags/index_spec.rb b/spec/requests/tags/api/v1/index_spec.rb similarity index 83% rename from spec/requests/tags/index_spec.rb rename to spec/requests/tags/api/v1/index_spec.rb index 0ae457d3..0d399820 100644 --- a/spec/requests/tags/index_spec.rb +++ b/spec/requests/tags/api/v1/index_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" describe "Tags", type: :request do - describe "GET /tags" do + describe "GET /api/v1/tags" do let(:language) { create(:language) } let(:topic) { create(:topic, language: language) } let(:tag) { create(:tag) } @@ -14,14 +14,14 @@ it "renders a successful response" do tag_topic(topic, tag) - get tags_url, params: { language_id: topic.language.id } + get api_v1_tags_url, params: { language_id: topic.language.id } expect(response).to be_successful expect(assigns(:tags)).to eq([ tag ]) end it " renders a unsuccessful response" do - get tags_url + get api_v1_tags_url expect(response).not_to be_successful end From 1302f6c6d1ba0e16c6f29947d7ff82d6983f92aa Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Sat, 29 Mar 2025 16:14:43 +0100 Subject: [PATCH 05/18] feat(tags): add tag resource --- app/controllers/tags_controller.rb | 48 ++++++++++++++++++------- app/views/layouts/_sidebar.html.erb | 54 ++++++++++++++++------------- app/views/tags/_form.html.erb | 38 ++++++++++++++++++++ app/views/tags/_list.html.erb | 25 +++++++++++++ app/views/tags/_tag.html.erb | 20 +++++++++++ app/views/tags/edit.html.erb | 18 ++++++++++ app/views/tags/index.html.erb | 33 ++++++++++++++++++ app/views/tags/new.html.erb | 18 ++++++++++ app/views/tags/show.html.erb | 14 ++++++++ spec/requests/tags/create_spec.rb | 32 +++++++++++++++++ spec/requests/tags/destroy_spec.rb | 28 +++++++++++++++ spec/requests/tags/index_spec.rb | 20 +++++++++++ spec/requests/tags/update_spec.rb | 20 +++++++++++ 13 files changed, 332 insertions(+), 36 deletions(-) create mode 100644 app/views/tags/_form.html.erb create mode 100644 app/views/tags/_list.html.erb create mode 100644 app/views/tags/_tag.html.erb create mode 100644 app/views/tags/edit.html.erb create mode 100644 app/views/tags/index.html.erb create mode 100644 app/views/tags/new.html.erb create mode 100644 app/views/tags/show.html.erb create mode 100644 spec/requests/tags/create_spec.rb create mode 100644 spec/requests/tags/destroy_spec.rb create mode 100644 spec/requests/tags/index_spec.rb create mode 100644 spec/requests/tags/update_spec.rb diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index d65b0777..41262cd6 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,28 +1,52 @@ 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.all + 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? + @tag.destroy + redirect_to tags_path, notice: "Tag was successfully destroyed." 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/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/tags/_form.html.erb b/app/views/tags/_form.html.erb new file mode 100644 index 00000000..ce3a2e6a --- /dev/null +++ b/app/views/tags/_form.html.erb @@ -0,0 +1,38 @@ +<%= form_for tag do |f| %> +
+
+ <%= render "shared/errors", resource: tag %> +
+
+ <%= f.label :name %> + <%= f.text_field :name, class: "form-control", placeholder: "Name" %> +
+
+
+
+ <%= f.label :tag_list, class: "flex-fill" %> + Press enter to add a new tag +
+ <%= f.select :cognates_list, + options_from_collection_for_select( + tag.available_cognates, + :name, + :name, + tag.cognates_list + ), + { prompt: "Select tags", include_blank: true }, + multiple: true, + class: "form-select", + data: { "allow-new": "true", "allow-clear": "true", "select-tags-target": "tagList" } + %> +
+
+ +
+ <%= f.submit class: "btn btn-primary me-1 mb-1" %> + <%= link_to "Cancel", tags_path, class: "btn btn-light-secondary me-1 mb-1" %> +
+
+
+
+<% end %> diff --git a/app/views/tags/_list.html.erb b/app/views/tags/_list.html.erb new file mode 100644 index 00000000..5ab36564 --- /dev/null +++ b/app/views/tags/_list.html.erb @@ -0,0 +1,25 @@ + + <% @tags.each do |tag| %> + + + <%= tag.name %> + + + <% tag.cognates.each do |cognate| %> + <%= link_to cognate.name, tag_path(cognate), class: "badge bg-light-primary" %> + <% end %> + <% tag.reverse_cognates.each do |cognate| %> + <%= link_to cognate.name, tag_path(cognate), class: "badge bg-light-primary" %> + <% end %> + + + <%= link_to tag, class: "btn btn-primary btn-sm", data: {turbo: false } do %> + View + <% end %> + <%= link_to edit_tag_path(tag), class: "btn btn-secondary btn-sm", data: {turbo: false } do %> + Edit + <% end %> + + + <% end %> + diff --git a/app/views/tags/_tag.html.erb b/app/views/tags/_tag.html.erb new file mode 100644 index 00000000..f79852b8 --- /dev/null +++ b/app/views/tags/_tag.html.erb @@ -0,0 +1,20 @@ +
+

+ Name: + <%= tag.name %> +

+ +

+ Cognates: + <% tag.cognates.each do |cognate| %> + <%= link_to cognate.name, tag_path(cognate), class: "badge bg-success" %> + <% end %> +

+ +

+ Reverse cognates: + <% tag.reverse_cognates.each do |cognate| %> + <%= link_to cognate.name, tag_path(cognate), class: "badge bg-light-success" %> + <% end %> +

+
diff --git a/app/views/tags/edit.html.erb b/app/views/tags/edit.html.erb new file mode 100644 index 00000000..b8752c93 --- /dev/null +++ b/app/views/tags/edit.html.erb @@ -0,0 +1,18 @@ +<% content_for :title, "Editing tag" %> + +
+
+
+
+
+

Edit tag

+
+
+
+ <%= render "form", tag: @tag %> +
+
+
+
+
+
diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb new file mode 100644 index 00000000..1ad8a09f --- /dev/null +++ b/app/views/tags/index.html.erb @@ -0,0 +1,33 @@ +<% content_for :title, "Tags" %> + +
+
+
+
+
+

Tags

+ <%= link_to new_tag_path, class: "btn btn-primary", data: { turbo: false } do %> + Add New Tag + <% end %> +
+
+
+

Some important information or instruction can be placed here.

+
+ + + + + + + + + <%= render "list", topics: @tags %> +
NameCognatesActions
+
+
+
+
+
+
+
diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb new file mode 100644 index 00000000..e8839897 --- /dev/null +++ b/app/views/tags/new.html.erb @@ -0,0 +1,18 @@ +<% content_for :title, "New tag" %> + +
+
+
+
+
+

New tag

+
+
+
+ <%= render "form", tag: @tag %> +
+
+
+
+
+
diff --git a/app/views/tags/show.html.erb b/app/views/tags/show.html.erb new file mode 100644 index 00000000..9a474a28 --- /dev/null +++ b/app/views/tags/show.html.erb @@ -0,0 +1,14 @@ +
<%= notice %>
+ +<%= render @tag %> + +
+ <%= link_to "Edit this tag", edit_tag_path(@tag) %> | + <%= link_to "Back to tags", tags_path %> +
+ +<% if Current.user.is_admin? %> +
+ <%= button_to "Delete this tag", @tag, method: :delete, class: "btn btn-danger mt-4" %> +
+<% end %> diff --git a/spec/requests/tags/create_spec.rb b/spec/requests/tags/create_spec.rb new file mode 100644 index 00000000..fb6604c6 --- /dev/null +++ b/spec/requests/tags/create_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +describe "Tags", type: :request do + describe "POST /tags" do + let(:user) { create(:user) } + let(:tag_params) { attributes_for(:tag, name: "Ruby") } + + before do + sign_in(user) + end + + it "creates a Tag" do + post tags_url, params: { tag: tag_params } + + expect(response).to redirect_to(tags_url) + tag = Tag.last + expect(tag.name).to eq("Ruby") + end + + context "when user is an admin" do + before { user.update(is_admin: true) } + + it "creates a Tag" do + post tags_url, params: { tag: tag_params.merge(name: "Perl") } + + expect(response).to redirect_to(tags_url) + tag = Tag.last + expect(tag.name).to eq("Perl") + end + end + end +end diff --git a/spec/requests/tags/destroy_spec.rb b/spec/requests/tags/destroy_spec.rb new file mode 100644 index 00000000..75dedba7 --- /dev/null +++ b/spec/requests/tags/destroy_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" + +describe "Tags", type: :request do + describe "DELETE /tags/:id" do + let(:user) { create(:user, :admin) } + let(:tag) { create(:tag) } + + before { sign_in(user) } + + it "deletes a Tag" do + delete tag_url(tag) + + expect(response).to redirect_to(tags_url) + expect(Tag.count).to be_zero + end + + context "when user is not an admin" do + let(:user) { create(:user) } + + it "does not delete a Tag" do + delete tag_url(tag) + + expect(response).to redirect_to(tags_url) + expect(Tag.count).to eq(1) + end + end + end +end diff --git a/spec/requests/tags/index_spec.rb b/spec/requests/tags/index_spec.rb new file mode 100644 index 00000000..bff37141 --- /dev/null +++ b/spec/requests/tags/index_spec.rb @@ -0,0 +1,20 @@ +require "rails_helper" + +describe "Tags", type: :request do + describe "GET /tags" do + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it "renders a successful response" do + tag = create(:tag) + + get tags_url + + expect(response).to be_successful + expect(assigns(:tags)).to eq([ tag ]) + end + end +end diff --git a/spec/requests/tags/update_spec.rb b/spec/requests/tags/update_spec.rb new file mode 100644 index 00000000..a46aa325 --- /dev/null +++ b/spec/requests/tags/update_spec.rb @@ -0,0 +1,20 @@ +require "rails_helper" + +describe "Tag", type: :request do + describe "PUT /tags/:id" do + let(:user) { create(:user) } + + before { sign_in(user) } + + it "updates a Tag" do + tag = create(:tag, name: "Java") + tag_params = { name: "Ruby" } + + put tag_url(tag), params: { tag: tag_params } + + tag.reload + expect(response).to redirect_to(tags_url) + expect(tag.name).to eq("Ruby") + end + end +end From 6bdb57ae37c0c460cae3bb59e215ecdb7379134a Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Sat, 29 Mar 2025 17:50:09 +0100 Subject: [PATCH 06/18] test(tags): add specs --- app/models/tag_cognate.rb | 11 +++++++++-- spec/factories/tag_cognates.rb | 6 ++++++ spec/factories/tags.rb | 2 +- spec/models/tag_cognate_spec.rb | 25 +++++++++++++++++++++++++ spec/models/tag_spec.rb | 10 ++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 spec/factories/tag_cognates.rb create mode 100644 spec/models/tag_cognate_spec.rb create mode 100644 spec/models/tag_spec.rb diff --git a/app/models/tag_cognate.rb b/app/models/tag_cognate.rb index 67756197..34d76d19 100644 --- a/app/models/tag_cognate.rb +++ b/app/models/tag_cognate.rb @@ -20,8 +20,15 @@ # fk_rails_... (tag_id => tags.id) # class TagCognate < ApplicationRecord - belongs_to :tag, class_name: "ActsAsTaggableOn::Tag" - belongs_to :cognate, class_name: "ActsAsTaggableOn::Tag" + 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/spec/factories/tag_cognates.rb b/spec/factories/tag_cognates.rb new file mode 100644 index 00000000..1dd4338e --- /dev/null +++ b/spec/factories/tag_cognates.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :tag_cognate do + association :tag + association :cognate, factory: :tag + end +end diff --git a/spec/factories/tags.rb b/spec/factories/tags.rb index 6db88b23..92b8b491 100644 --- a/spec/factories/tags.rb +++ b/spec/factories/tags.rb @@ -1,5 +1,5 @@ FactoryBot.define do - factory :tag, class: ActsAsTaggableOn::Tag do + factory :tag do name { Faker::ProgrammingLanguage.name } trait :english do diff --git a/spec/models/tag_cognate_spec.rb b/spec/models/tag_cognate_spec.rb new file mode 100644 index 00000000..886bdb3a --- /dev/null +++ b/spec/models/tag_cognate_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" + +RSpec.describe TagCognate, type: :model do + describe "associations" do + it { should belong_to(:tag) } + it { should belong_to(:cognate).class_name("Tag") } + end + + describe "validations" do + let(:tag) { create(:tag) } + let(:cognate) { create(:tag) } + subject { build(:tag_cognate, tag: tag, cognate: cognate) } + + it "validates uniqueness of cognate scoped to tag" do + should validate_uniqueness_of(:tag_id).scoped_to(:cognate_id) + end + + it "prevents self-referential relationships" do + tag_cognate = build(:tag_cognate, tag: tag, cognate: tag) + + expect(tag_cognate).not_to be_valid + expect(tag_cognate.errors[:base]).to include("Tag can't be its own cognate") + end + end +end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb new file mode 100644 index 00000000..2bd31792 --- /dev/null +++ b/spec/models/tag_spec.rb @@ -0,0 +1,10 @@ +require "rails_helper" + +RSpec.describe Tag, type: :model do + describe "associations" do + it { should have_many(:tag_cognates) } + it { should have_many(:cognates).through(:tag_cognates) } + it { should have_many(:reverse_tag_cognates).class_name("TagCognate") } + it { should have_many(:reverse_cognates).through(:reverse_tag_cognates) } + end +end From f171924b61d1070b6e55e4cef15b22ec73b9ccaf Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Sat, 29 Mar 2025 17:51:26 +0100 Subject: [PATCH 07/18] chore(tags): tags index loads relations --- app/controllers/tags_controller.rb | 2 +- app/views/tags/_list.html.erb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 41262cd6..b9e71b48 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -3,7 +3,7 @@ class TagsController < ApplicationController before_action :set_tag, only: [ :show, :edit, :update, :destroy ] def index - @tags = Tag.all + @tags = Tag.includes(:cognates, :reverse_cognates).references(:tag) end def new diff --git a/app/views/tags/_list.html.erb b/app/views/tags/_list.html.erb index 5ab36564..caeb35ce 100644 --- a/app/views/tags/_list.html.erb +++ b/app/views/tags/_list.html.erb @@ -6,10 +6,10 @@ <% tag.cognates.each do |cognate| %> - <%= link_to cognate.name, tag_path(cognate), class: "badge bg-light-primary" %> + <%= link_to cognate.name, tag_path(cognate), class: "badge bg-success" %> <% end %> <% tag.reverse_cognates.each do |cognate| %> - <%= link_to cognate.name, tag_path(cognate), class: "badge bg-light-primary" %> + <%= link_to cognate.name, tag_path(cognate), class: "badge bg-light-success" %> <% end %> From 3b4a8bb4bab3341057a45825ed0e7d66bf828f3c Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Tue, 4 Mar 2025 18:34:07 +0100 Subject: [PATCH 08/18] chore: add cognate_tags method --- app/models/tag.rb | 19 ++++++++++---- app/views/tags/_list.html.erb | 5 +--- spec/models/tag_spec.rb | 49 +++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/models/tag.rb b/app/models/tag.rb index 1861abde..d428a3d7 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -6,15 +6,24 @@ class Tag < ActsAsTaggableOn::Tag has_many :reverse_tag_cognates, class_name: "TagCognate", foreign_key: :cognate_id has_many :reverse_cognates, through: :reverse_tag_cognates, source: :tag - def cognates_list - cognates.pluck(:name) + reverse_cognates.pluck(:name) + # 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 - def cognates_list=(cognates_list_str) - self.cognates = Tag.where(name: cognates_list_str) + # 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 + # Returns tags that are available to be set as cognates + # + # @return [ActiveRecord::Relation] collection of Tag records excluding self and existing cognates def available_cognates - Tag.all - [ cognates, self ] + Tag.where.not(id: cognates.pluck(:id)).where.not(id: id) end end diff --git a/app/views/tags/_list.html.erb b/app/views/tags/_list.html.erb index caeb35ce..c4f68722 100644 --- a/app/views/tags/_list.html.erb +++ b/app/views/tags/_list.html.erb @@ -5,12 +5,9 @@ <%= tag.name %> - <% tag.cognates.each do |cognate| %> + <% tag.cognates_tags.each do |cognate| %> <%= link_to cognate.name, tag_path(cognate), class: "badge bg-success" %> <% end %> - <% tag.reverse_cognates.each do |cognate| %> - <%= link_to cognate.name, tag_path(cognate), class: "badge bg-light-success" %> - <% end %> <%= link_to tag, class: "btn btn-primary btn-sm", data: {turbo: false } do %> diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 2bd31792..aa388795 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -1,10 +1,59 @@ require "rails_helper" RSpec.describe Tag, type: :model do + subject { create(:tag) } + describe "associations" do it { should have_many(:tag_cognates) } it { should have_many(:cognates).through(:tag_cognates) } it { should have_many(:reverse_tag_cognates).class_name("TagCognate") } it { should have_many(:reverse_cognates).through(:reverse_tag_cognates) } end + + describe ".cognates_tags" do + it "returns all tags that are cognates of the given tag" do + cognate_tag = create(:tag) + create(:tag_cognate, tag: subject, cognate: cognate_tag) + + expect(subject.cognates_tags).to include(cognate_tag) + end + + context "with reverse cognates" do + it "returns all tags that are cognates of the given tag" do + another_tag = create(:tag) + create(:tag_cognate, tag: another_tag, cognate: subject) + + expect(subject.cognates_tags).to include(another_tag) + end + end + end + + describe ".cognates_list" do + it "returns all tags that are cognates of the given tag" do + cognate_tag = create(:tag) + create(:tag_cognate, tag: subject, cognate: cognate_tag) + + expect(subject.cognates_list).to include(cognate_tag.name) + end + + context "with reverse cognates" do + it "returns all tags that are cognates of the given tag" do + another_tag = create(:tag) + create(:tag_cognate, tag: another_tag, cognate: subject) + + expect(subject.cognates_list).to include(another_tag.name) + end + end + end + + describe ".available_cognates" do + it "returns all tags that are not cognates of the given tag" do + non_cognate_tags = create_list(:tag, 2) + cognate_tag = create(:tag) + create(:tag_cognate, tag: subject, cognate: cognate_tag) + + expect(subject.available_cognates).not_to include(cognate_tag) + expect(subject.available_cognates).to be_all { |tag| non_cognate_tags.include?(tag) } + end + end end From b05b53fddd0d3e92fb2b2856a6949272531aefc5 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Mon, 7 Apr 2025 21:38:01 +0200 Subject: [PATCH 09/18] test(tags): add tagging test helpers --- spec/rails_helper.rb | 3 +++ spec/support/tagging_helpers.rb | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 spec/support/tagging_helpers.rb diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 23db340f..1190f350 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -68,4 +68,7 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + + config.include SystemHelpers, type: :system + config.include TaggingHelpers, type: :system end diff --git a/spec/support/tagging_helpers.rb b/spec/support/tagging_helpers.rb new file mode 100644 index 00000000..a22dc150 --- /dev/null +++ b/spec/support/tagging_helpers.rb @@ -0,0 +1,35 @@ +module TaggingHelpers + # Selects a tag in a form using the select-tags Stimulus controller + # + # @param tag_name [String] the name of the tag to be selected + # + # @example + # select_tag("ruby") # Enters the tag "ruby" in the form + # + # @note This helper assumes the presence of a Stimulus controller 'select-tags' + # which renders a specific DOM structure + def select_tag(tag_name) + within "div[data-controller='select-tags']" do + tag_input = find("div.form-control.dropdown.form-select>div>input") + tag_input.fill_in(with: tag_name) + tag_input.send_keys(:enter) + end + end + + # Verifies that specific tags are present in a topic's detail page + # + # @param title [String] the title of the topic to check + # @param expected_tags [Array] list of tags that should be present + # + # @example + # verify_tags_in_topic_page("My Ruby Topic", ["ruby", "programming"]) + # + # @note This helper navigates to the topic's show page for verification + # as tags are only visible in the detail view + def verify_tags_in_topic_page(title, expected_tags) + visit_with_wait(topic_path(Topic.find_by(title: title))) + expected_tags.each do |tag| + expect(page).to have_text(tag) + end + end +end From e42e01cc3871836d207cc3d5a2c9c614d0c61f72 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Mon, 7 Apr 2025 21:38:54 +0200 Subject: [PATCH 10/18] feat(topics): show associated tags --- app/models/concerns/localized_taggable.rb | 9 ++++++ app/views/topics/_topic.html.erb | 36 +++++++++++++++-------- 2 files changed, 33 insertions(+), 12 deletions(-) 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/views/topics/_topic.html.erb b/app/views/topics/_topic.html.erb index 31c2340e..e6da56d3 100644 --- a/app/views/topics/_topic.html.erb +++ b/app/views/topics/_topic.html.erb @@ -1,4 +1,9 @@
+

+ UID: + <%= topic.uid %> +

+

Title: <%= topic.title %> @@ -10,8 +15,8 @@

- UID: - <%= topic.uid %> + Provider: + <%= link_to topic.provider.name, topic.provider %>

@@ -19,19 +24,26 @@ <%= link_to topic.language.name, topic.language %>

-

- Provider: - <%= link_to topic.provider.name, topic.provider %> -

-

Publishing at: <%= topic.published_at.strftime('%m/%d/%Y') %>

-
    - <% topic.documents.each do |document| %> -
  • <%= link_to document.filename, rails_blob_path(document), target: "_blank"%>
  • - <% end %> -
+
+

+ Tags: + <% topic.current_tags.each do |tag| %> + <%= link_to tag.name, tag_path(tag), class: "badge bg-success", target: "_blank" %> + <% end %> +

+
+ +
+ Documents: +
    + <% topic.documents.each do |document| %> +
  • <%= link_to document.filename, rails_blob_path(document), target: "_blank"%>
  • + <% end %> +
+
From 8e1a1478db8b317a9e8943d640eb09314731011b Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Mon, 7 Apr 2025 21:39:33 +0200 Subject: [PATCH 11/18] test(topics): verify associated tags on create --- spec/system/topic_creation_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/system/topic_creation_spec.rb b/spec/system/topic_creation_spec.rb index af5ac714..0d1ee638 100644 --- a/spec/system/topic_creation_spec.rb +++ b/spec/system/topic_creation_spec.rb @@ -4,6 +4,7 @@ describe "there is a create topic button" do let!(:english) { create(:language, name: "English") } let!(:provider) { create(:provider) } + let!(:tag_name) { "tag1" } before do login_as(user) @@ -18,9 +19,12 @@ fill_in "Title", with: "My Topic" select "English", from: "topic_language_id" select provider.name, from: "topic_provider_id" + select_tag(tag_name) click_button("Create Topic") expect(page).to have_text("Search") expect(page).to have_text("My Topic") + + verify_tags_in_topic_page("My Topic", [ tag_name ]) end end From 386e407d7455f0753ca6333448082c8213e9e243 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Tue, 8 Apr 2025 17:41:02 +0200 Subject: [PATCH 12/18] feat(tags): enhance tag model --- app/models/tag.rb | 42 +++++++++++++++++++--- app/views/tags/_form.html.erb | 2 +- spec/models/tag_spec.rb | 67 ++++++++++++++++++++++++++++++++--- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/app/models/tag.rb b/app/models/tag.rb index d428a3d7..157eb0d6 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,6 +1,7 @@ class Tag < ActsAsTaggableOn::Tag - has_many :tag_cognates + 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 @@ -20,10 +21,43 @@ 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 and existing cognates - def available_cognates - Tag.where.not(id: cognates.pluck(:id)).where.not(id: id) + # @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/views/tags/_form.html.erb b/app/views/tags/_form.html.erb index ce3a2e6a..47ffd5fe 100644 --- a/app/views/tags/_form.html.erb +++ b/app/views/tags/_form.html.erb @@ -15,7 +15,7 @@ <%= f.select :cognates_list, options_from_collection_for_select( - tag.available_cognates, + tag.all_available_tags, :name, :name, tag.cognates_list diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index aa388795..9f5eda38 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -10,6 +10,26 @@ it { should have_many(:reverse_cognates).through(:reverse_tag_cognates) } end + describe "validations" do + it { should validate_presence_of(:name) } + it { should validate_uniqueness_of(:name) } + end + + describe ".destroy" do + context "when a tag has cognates" do + let(:tag) { create(:tag) } + let(:cognate_tag) { create(:tag) } + let!(:tag_cognate) { create(:tag_cognate, tag: tag, cognate: cognate_tag) } + + it "destroys all associated tag cognates" do + expect { tag.destroy } + .to change { TagCognate.exists?(tag_id: tag.id, cognate_id: cognate_tag.id) } + .from(true) + .to(false) + end + end + end + describe ".cognates_tags" do it "returns all tags that are cognates of the given tag" do cognate_tag = create(:tag) @@ -46,14 +66,53 @@ end end - describe ".available_cognates" do + describe ".cognates_list=" do + context "when setting cognates" do + let(:cognate_tag) { create(:tag) } + let!(:existing_cognate) { create(:tag) } + + it "adds new cognates to the tag" do + subject.cognates_list = [ cognate_tag.name ] + expect(subject.cognates_tags).to include(cognate_tag) + end + + context "when existing cognates are present" do + let(:new_cognate_tag) { create(:tag) } + + before do + create(:tag_cognate, tag: subject, cognate: existing_cognate) + end + + it "replaces old cognates with new ones" do + subject.cognates_list = [ new_cognate_tag.name ] + + aggregate_failures do + expect(subject.cognates_tags).to include(new_cognate_tag) + expect(subject.cognates_tags).not_to include(existing_cognate) + end + end + end + end + + context "when setting an empty cognates list" do + before do + create(:tag_cognate, tag: subject, cognate: create(:tag)) + end + + it "removes all cognates" do + subject.cognates_list = [] + expect(subject.cognates_tags).to be_empty + end + end + end + + describe ".all_available_tags" do it "returns all tags that are not cognates of the given tag" do - non_cognate_tags = create_list(:tag, 2) cognate_tag = create(:tag) create(:tag_cognate, tag: subject, cognate: cognate_tag) - expect(subject.available_cognates).not_to include(cognate_tag) - expect(subject.available_cognates).to be_all { |tag| non_cognate_tags.include?(tag) } + expect(subject.all_available_tags).to include(cognate_tag) + expect(subject.all_available_tags).not_to include(subject) end end end From a0953219f42e72d2d0ba7587403ce0823b0bceb4 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Tue, 8 Apr 2025 17:46:41 +0200 Subject: [PATCH 13/18] test(tags): extend specs --- spec/requests/tags/create_spec.rb | 5 +++-- spec/requests/tags/update_spec.rb | 5 +++-- spec/support/tagging_helpers.rb | 4 ++-- spec/system/tag_creation_spec.rb | 21 +++++++++++++++++++++ spec/system/tag_update_spec.rb | 22 ++++++++++++++++++++++ spec/system/topic_creation_spec.rb | 5 ++++- 6 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 spec/system/tag_creation_spec.rb create mode 100644 spec/system/tag_update_spec.rb diff --git a/spec/requests/tags/create_spec.rb b/spec/requests/tags/create_spec.rb index fb6604c6..f1c0482e 100644 --- a/spec/requests/tags/create_spec.rb +++ b/spec/requests/tags/create_spec.rb @@ -3,7 +3,7 @@ describe "Tags", type: :request do describe "POST /tags" do let(:user) { create(:user) } - let(:tag_params) { attributes_for(:tag, name: "Ruby") } + let(:tag_params) { attributes_for(:tag, name: "Lisp", cognates_list: [ "Common Lisp" ]) } before do sign_in(user) @@ -14,7 +14,8 @@ expect(response).to redirect_to(tags_url) tag = Tag.last - expect(tag.name).to eq("Ruby") + expect(tag.name).to eq("Lisp") + expect(tag.cognates_list).to eq([ "Common Lisp" ]) end context "when user is an admin" do diff --git a/spec/requests/tags/update_spec.rb b/spec/requests/tags/update_spec.rb index a46aa325..026714f4 100644 --- a/spec/requests/tags/update_spec.rb +++ b/spec/requests/tags/update_spec.rb @@ -8,13 +8,14 @@ it "updates a Tag" do tag = create(:tag, name: "Java") - tag_params = { name: "Ruby" } + tag_params = { name: "Lisp", cognates_list: [ "Common Lisp" ] } put tag_url(tag), params: { tag: tag_params } tag.reload expect(response).to redirect_to(tags_url) - expect(tag.name).to eq("Ruby") + expect(tag.name).to eq("Lisp") + expect(tag.cognates_list).to eq([ "Common Lisp" ]) end end end diff --git a/spec/support/tagging_helpers.rb b/spec/support/tagging_helpers.rb index a22dc150..5df167b6 100644 --- a/spec/support/tagging_helpers.rb +++ b/spec/support/tagging_helpers.rb @@ -8,7 +8,7 @@ module TaggingHelpers # # @note This helper assumes the presence of a Stimulus controller 'select-tags' # which renders a specific DOM structure - def select_tag(tag_name) + def enter_and_select_tag(tag_name) within "div[data-controller='select-tags']" do tag_input = find("div.form-control.dropdown.form-select>div>input") tag_input.fill_in(with: tag_name) @@ -27,7 +27,7 @@ def select_tag(tag_name) # @note This helper navigates to the topic's show page for verification # as tags are only visible in the detail view def verify_tags_in_topic_page(title, expected_tags) - visit_with_wait(topic_path(Topic.find_by(title: title))) + wait_and_visit(topic_path(Topic.find_by(title: title))) expected_tags.each do |tag| expect(page).to have_text(tag) end diff --git a/spec/system/tag_creation_spec.rb b/spec/system/tag_creation_spec.rb new file mode 100644 index 00000000..2923078d --- /dev/null +++ b/spec/system/tag_creation_spec.rb @@ -0,0 +1,21 @@ +require "rails_helper" + +RSpec.describe "Creating a Tag", type: :system do + let(:user) { create(:user, :admin) } + + before do + login_as(user) + wait_and_visit(new_tag_path) + end + + context "as an Admin" do + it "creates a tag" do + fill_in "Name", with: "Erlang" + enter_and_select_tag("OTP") + + click_button "Create Tag" + expect(page).to have_content("Erlang") + expect(page).to have_content("OTP") + end + end +end diff --git a/spec/system/tag_update_spec.rb b/spec/system/tag_update_spec.rb new file mode 100644 index 00000000..ec1fe044 --- /dev/null +++ b/spec/system/tag_update_spec.rb @@ -0,0 +1,22 @@ +require "rails_helper" + +RSpec.describe "Updating a Tag", type: :system do + let(:user) { create(:user, :admin) } + let(:tag) { create(:tag, name: "Python") } + + before do + login_as(user) + wait_and_visit(edit_tag_path(tag.id)) + end + + context "as an Admin" do + it "updates a tag" do + fill_in "Name", with: "JavaScript" + enter_and_select_tag("TypeScript") + + click_button "Update Tag" + expect(page).to have_content("JavaScript") + expect(page).to have_content("TypeScript") + end + end +end diff --git a/spec/system/topic_creation_spec.rb b/spec/system/topic_creation_spec.rb index 0d1ee638..0c92da2e 100644 --- a/spec/system/topic_creation_spec.rb +++ b/spec/system/topic_creation_spec.rb @@ -19,7 +19,7 @@ fill_in "Title", with: "My Topic" select "English", from: "topic_language_id" select provider.name, from: "topic_provider_id" - select_tag(tag_name) + enter_and_select_tag(tag_name) click_button("Create Topic") expect(page).to have_text("Search") expect(page).to have_text("My Topic") @@ -45,9 +45,12 @@ it "creates a Topic" do fill_in "Title", with: "My Topic" select "English", from: "topic_language_id" + enter_and_select_tag(tag_name) click_button("Create Topic") expect(page).to have_text("Search") expect(page).to have_text("My Topic") + + verify_tags_in_topic_page("My Topic", [ tag_name ]) end end From 5c3cfea69fc0e28908409adb5547a022313152e0 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Wed, 9 Apr 2025 21:12:10 +0200 Subject: [PATCH 14/18] feat(topics): search by tags --- app/controllers/topics_controller.rb | 2 +- .../controllers/select_tags_controller.js | 4 ++++ app/models/concerns/searcheable.rb | 5 ++++ app/views/topics/_search.html.erb | 24 ++++++++++++++++++- spec/support/tagging_helpers.rb | 16 +++++++++++++ spec/system/topic_search_spec.rb | 13 ++++++++++ 6 files changed, 62 insertions(+), 2 deletions(-) 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/javascript/controllers/select_tags_controller.js b/app/javascript/controllers/select_tags_controller.js index dabe878c..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 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/views/topics/_search.html.erb b/app/views/topics/_search.html.erb index 2e7369e2..35d3914a 100644 --- a/app/views/topics/_search.html.erb +++ b/app/views/topics/_search.html.erb @@ -7,12 +7,34 @@ <%= form_for :search, url: topics_path, method: :get, data: { controller: "topics", topics_target: "searchForm", turbo_frame: "topic-list", turbo_action: "advance" } do |f| %>
-
+
<%= f.label :query %> <%= f.text_field :query, value: params[:query], class: "form-control", data: { action: "input->topics#searchTopics" } %>
+
+
+
+ <%= f.label :tag_list, class: "flex-fill" %> + Press enter to add a new tag +
+ <%= f.select :tag_list, + options_from_collection_for_select( + Tag.all, + :name, + :name, + [] + ), + { prompt: "Select tags", include_hidden: false }, + multiple: true, + class: "form-select", + data: { "allow-clear": "true", "select-tags-target": "tagList", "action": "change->select-tags#notify" } + %> +
+
<%= f.label :language %> diff --git a/spec/support/tagging_helpers.rb b/spec/support/tagging_helpers.rb index 5df167b6..dd45eb1e 100644 --- a/spec/support/tagging_helpers.rb +++ b/spec/support/tagging_helpers.rb @@ -8,6 +8,22 @@ module TaggingHelpers # # @note This helper assumes the presence of a Stimulus controller 'select-tags' # which renders a specific DOM structure + def select_tag(tag_name) + within "div[data-controller='select-tags']" do + select_input = find("select#search_tag_list", visible: false) + select_input.select(tag_name) + end + end + + # Enter and selects a tag in a form using the select-tags Stimulus controller + # + # @param tag_name [String] the name of the tag to be selected + # + # @example + # select_tag("ruby") # Enters the tag "ruby" in the form + # + # @note This helper assumes the presence of a Stimulus controller 'select-tags' + # which renders a specific DOM structure def enter_and_select_tag(tag_name) within "div[data-controller='select-tags']" do tag_input = find("div.form-control.dropdown.form-select>div>input") diff --git a/spec/system/topic_search_spec.rb b/spec/system/topic_search_spec.rb index 32d9c42d..88f32090 100644 --- a/spec/system/topic_search_spec.rb +++ b/spec/system/topic_search_spec.rb @@ -128,6 +128,19 @@ end end + context "when searching by tags" do + it "only displays topics matching the search" do + english_active_topic.set_tag_list_on(english_active_topic.language.code.to_sym, "Basic") + english_active_topic.save + + select_tag("Basic") + + expect(page).to have_text(english_active_topic.title) + expect(page).not_to have_text(spanish_active_topic.title) + expect(page).not_to have_text(english_archived_topic.title) + end + end + context "when sorting" do it "displays users in the selected order" do select "asc", from: "search_order" From 8566eebdb7757954eb8307014ceaf4d4390bf3a4 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Thu, 10 Apr 2025 14:53:26 +0200 Subject: [PATCH 15/18] feat(tag): show tag tagging count --- app/views/tags/_list.html.erb | 3 +++ app/views/tags/_tag.html.erb | 2 ++ app/views/tags/_taggings.html.erb | 11 +++++++++++ app/views/tags/index.html.erb | 1 + 4 files changed, 17 insertions(+) create mode 100644 app/views/tags/_taggings.html.erb diff --git a/app/views/tags/_list.html.erb b/app/views/tags/_list.html.erb index c4f68722..ae799fcd 100644 --- a/app/views/tags/_list.html.erb +++ b/app/views/tags/_list.html.erb @@ -9,6 +9,9 @@ <%= link_to cognate.name, tag_path(cognate), class: "badge bg-success" %> <% end %> + + <%= tag.taggings_count %> + <%= link_to tag, class: "btn btn-primary btn-sm", data: {turbo: false } do %> View diff --git a/app/views/tags/_tag.html.erb b/app/views/tags/_tag.html.erb index f79852b8..1cb7ad62 100644 --- a/app/views/tags/_tag.html.erb +++ b/app/views/tags/_tag.html.erb @@ -17,4 +17,6 @@ <%= link_to cognate.name, tag_path(cognate), class: "badge bg-light-success" %> <% end %>

+ + <%= render("tags/taggings", tag: tag) if tag.taggings_count.positive? %>
diff --git a/app/views/tags/_taggings.html.erb b/app/views/tags/_taggings.html.erb new file mode 100644 index 00000000..749e5848 --- /dev/null +++ b/app/views/tags/_taggings.html.erb @@ -0,0 +1,11 @@ +

+ This tag is associated with <%= @tag.taggings_count %> resource(s). +

+ +
    + <% tag.taggings.each do |tagging| %> +
  • + <%= link_to tagging.taggable.title, url_for(tagging.taggable), target: "_blank" %> +
  • + <% end %> +
diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb index 1ad8a09f..b61c6b69 100644 --- a/app/views/tags/index.html.erb +++ b/app/views/tags/index.html.erb @@ -19,6 +19,7 @@ Name Cognates + Taggings Actions From 469f6c138f12e8ff6c9b3dd379626f8bcbad40c1 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Thu, 10 Apr 2025 15:53:10 +0200 Subject: [PATCH 16/18] test: improve naming and topic tagged reference --- spec/support/tagging_helpers.rb | 10 +++++----- spec/system/tag_creation_spec.rb | 2 +- spec/system/tag_update_spec.rb | 2 +- spec/system/topic_creation_spec.rb | 4 ++-- spec/system/topic_search_spec.rb | 14 +++++++++----- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/spec/support/tagging_helpers.rb b/spec/support/tagging_helpers.rb index dd45eb1e..d00dc799 100644 --- a/spec/support/tagging_helpers.rb +++ b/spec/support/tagging_helpers.rb @@ -4,13 +4,13 @@ module TaggingHelpers # @param tag_name [String] the name of the tag to be selected # # @example - # select_tag("ruby") # Enters the tag "ruby" in the form + # choose_tag("ruby") # Enters the tag "ruby" in the form # # @note This helper assumes the presence of a Stimulus controller 'select-tags' # which renders a specific DOM structure - def select_tag(tag_name) + def choose_tag(tag_name) within "div[data-controller='select-tags']" do - select_input = find("select#search_tag_list", visible: false) + select_input = find("select#search_tag_list", visible: :all) select_input.select(tag_name) end end @@ -20,11 +20,11 @@ def select_tag(tag_name) # @param tag_name [String] the name of the tag to be selected # # @example - # select_tag("ruby") # Enters the tag "ruby" in the form + # enter_and_choose_tag("ruby") # Enters the tag "ruby" in the form # # @note This helper assumes the presence of a Stimulus controller 'select-tags' # which renders a specific DOM structure - def enter_and_select_tag(tag_name) + def enter_and_choose_tag(tag_name) within "div[data-controller='select-tags']" do tag_input = find("div.form-control.dropdown.form-select>div>input") tag_input.fill_in(with: tag_name) diff --git a/spec/system/tag_creation_spec.rb b/spec/system/tag_creation_spec.rb index 2923078d..a758bd35 100644 --- a/spec/system/tag_creation_spec.rb +++ b/spec/system/tag_creation_spec.rb @@ -11,7 +11,7 @@ context "as an Admin" do it "creates a tag" do fill_in "Name", with: "Erlang" - enter_and_select_tag("OTP") + enter_and_choose_tag("OTP") click_button "Create Tag" expect(page).to have_content("Erlang") diff --git a/spec/system/tag_update_spec.rb b/spec/system/tag_update_spec.rb index ec1fe044..7e394401 100644 --- a/spec/system/tag_update_spec.rb +++ b/spec/system/tag_update_spec.rb @@ -12,7 +12,7 @@ context "as an Admin" do it "updates a tag" do fill_in "Name", with: "JavaScript" - enter_and_select_tag("TypeScript") + enter_and_choose_tag("TypeScript") click_button "Update Tag" expect(page).to have_content("JavaScript") diff --git a/spec/system/topic_creation_spec.rb b/spec/system/topic_creation_spec.rb index 0c92da2e..88040dce 100644 --- a/spec/system/topic_creation_spec.rb +++ b/spec/system/topic_creation_spec.rb @@ -19,7 +19,7 @@ fill_in "Title", with: "My Topic" select "English", from: "topic_language_id" select provider.name, from: "topic_provider_id" - enter_and_select_tag(tag_name) + enter_and_choose_tag(tag_name) click_button("Create Topic") expect(page).to have_text("Search") expect(page).to have_text("My Topic") @@ -45,7 +45,7 @@ it "creates a Topic" do fill_in "Title", with: "My Topic" select "English", from: "topic_language_id" - enter_and_select_tag(tag_name) + enter_and_choose_tag(tag_name) click_button("Create Topic") expect(page).to have_text("Search") expect(page).to have_text("My Topic") diff --git a/spec/system/topic_search_spec.rb b/spec/system/topic_search_spec.rb index 88f32090..5f360632 100644 --- a/spec/system/topic_search_spec.rb +++ b/spec/system/topic_search_spec.rb @@ -4,6 +4,7 @@ let(:admin) { create(:user, :admin, email: "admin@mail.com") } let(:english) { create(:language, name: "English") } let(:spanish) { create(:language, name: "Spanish") } + let(:tag_name) { create(:language, name: "Basic") } let!(:spanish_active_topic) do create( :topic, @@ -33,6 +34,12 @@ ) end + let!(:english_topic_tagged) do + english_active_topic.set_tag_list_on(english.code.to_sym, tag_name) + english_active_topic.save + english_active_topic.reload + end + before do login_as(admin) click_link("Topics") @@ -130,12 +137,9 @@ context "when searching by tags" do it "only displays topics matching the search" do - english_active_topic.set_tag_list_on(english_active_topic.language.code.to_sym, "Basic") - english_active_topic.save + choose_tag(tag_name) - select_tag("Basic") - - expect(page).to have_text(english_active_topic.title) + expect(page).to have_text(english_topic_tagged.title) expect(page).not_to have_text(spanish_active_topic.title) expect(page).not_to have_text(english_archived_topic.title) end From 34fe91bad631f44d69fdb3c490fdba6555c27ffe Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Fri, 11 Apr 2025 08:21:14 +0200 Subject: [PATCH 17/18] feat(tags): add tag deletion confirmation --- app/controllers/tags_controller.rb | 12 ++++++++++-- app/views/tags/destroy.turbo_stream.erb | 14 ++++++++++++++ app/views/tags/show.html.erb | 20 +++++++++++--------- spec/requests/tags/destroy_spec.rb | 23 ++++++++++++++++++----- spec/system/tag_delete_spec.rb | 24 ++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 app/views/tags/destroy.turbo_stream.erb create mode 100644 spec/system/tag_delete_spec.rb diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index b9e71b48..15a43a74 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -36,8 +36,16 @@ def update def destroy redirect_to tags_path and return unless Current.user.is_admin? - @tag.destroy - redirect_to tags_path, notice: "Tag was successfully destroyed." + + 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 private diff --git a/app/views/tags/destroy.turbo_stream.erb b/app/views/tags/destroy.turbo_stream.erb new file mode 100644 index 00000000..66ca0cfc --- /dev/null +++ b/app/views/tags/destroy.turbo_stream.erb @@ -0,0 +1,14 @@ +<%= turbo_stream.update "delete_tag_#{@tag.id}" do %> +
+
Are you sure you want to delete this tag?
+ +
+
+ <%= button_to "Confirm Delete", tag_path(@tag, confirmed: true), method: :delete, class: "btn btn-danger" %> +
+
+ <%= link_to "Cancel", tags_path, class: "btn btn-secondary" %> +
+
+
+<% end %> diff --git a/app/views/tags/show.html.erb b/app/views/tags/show.html.erb index 9a474a28..7752c2d3 100644 --- a/app/views/tags/show.html.erb +++ b/app/views/tags/show.html.erb @@ -2,13 +2,15 @@ <%= render @tag %> -
- <%= link_to "Edit this tag", edit_tag_path(@tag) %> | - <%= link_to "Back to tags", tags_path %> -
- -<% if Current.user.is_admin? %> -
- <%= button_to "Delete this tag", @tag, method: :delete, class: "btn btn-danger mt-4" %> +
"> +
+ <%= link_to "Edit this tag", edit_tag_path(@tag) %> | + <%= link_to "Back to tags", tags_path %>
-<% end %> + + <% if Current.user.is_admin? %> +
+ <%= button_to "Delete this tag", @tag, method: :delete, class: "btn btn-danger mt-4" %> +
+ <% end %> +
diff --git a/spec/requests/tags/destroy_spec.rb b/spec/requests/tags/destroy_spec.rb index 75dedba7..5bd62d4a 100644 --- a/spec/requests/tags/destroy_spec.rb +++ b/spec/requests/tags/destroy_spec.rb @@ -4,20 +4,33 @@ describe "DELETE /tags/:id" do let(:user) { create(:user, :admin) } let(:tag) { create(:tag) } + let(:turbo_stream_headers) { { Accept: "text/vnd.turbo-stream.html" } } before { sign_in(user) } - it "deletes a Tag" do - delete tag_url(tag) + context "when requesting deletion confirmation" do + it "renders turbo stream to confirm deletion" do + delete tag_url(tag), headers: turbo_stream_headers - expect(response).to redirect_to(tags_url) - expect(Tag.count).to be_zero + expect(Tag.count).to be 1 + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq "text/vnd.turbo-stream.html" + end + end + + context "when deletion is confirmed" do + it "deletes a Tag" do + delete tag_url(tag, confirmed: true) + + expect(response).to redirect_to(tags_url) + expect(Tag.count).to be_zero + end end context "when user is not an admin" do let(:user) { create(:user) } - it "does not delete a Tag" do + it "preserves the tag and redirects" do delete tag_url(tag) expect(response).to redirect_to(tags_url) diff --git a/spec/system/tag_delete_spec.rb b/spec/system/tag_delete_spec.rb new file mode 100644 index 00000000..915b61b7 --- /dev/null +++ b/spec/system/tag_delete_spec.rb @@ -0,0 +1,24 @@ +require "rails_helper" + +RSpec.describe "Deleting a Tag", type: :system do + let(:user) { create(:user, :admin) } + let(:tag) { create(:tag, name: "Tool") } + + before do + login_as(user) + wait_and_visit(tag_path(tag)) + end + + context "as an Admin" do + it "deletes a tag" do + click_button "Delete this tag" + expect(page).to have_content("Are you sure you want to delete this tag?") + + click_button "Delete" + expect(page).to have_content("Tag was successfully destroyed.") + + expect(page).to have_content("Tags") + expect(page).not_to have_content("Tool") + end + end +end From f7a9fb0bee46dfc47010fd565d841021ce220541 Mon Sep 17 00:00:00 2001 From: Hernan Maguina Date: Fri, 11 Apr 2025 16:46:37 +0200 Subject: [PATCH 18/18] feat(layout): normalize flash alerts --- app/helpers/application_helper.rb | 7 +++++++ app/views/layouts/application.html.erb | 2 ++ app/views/shared/_flash.html.erb | 8 ++++++++ 3 files changed, 17 insertions(+) create mode 100644 app/views/shared/_flash.html.erb 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/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" %>