<%= f.label :tag_name, class: 'field-label' %>
<%= f.text_field :tag_name, autofocus: true, class: 'mt-1 block w-full rounded-md border-purple-500 shadow-xs focus:border-green-600 focus:ring-green-600 sm:text-sm' %>
<%= render ValidationErrorComponent.new(model: @tag, key: :tag_name) %>
<%= f.label :organization_id, class: 'field-label' %>
<%= f.collection_select :organization_id, @permitted_organizations, :id, :organization_name, {}, class: 'mt-1 block w-full rounded-md border-purple-500 shadow-xs focus:border-green-600 focus:ring-green-600 sm:text-sm' %>
<%= render ValidationErrorComponent.new(model: @tag, key: :organization) %>
+ <%= f.label :shared_organization_ids, class: 'field-label mb-1' %>
+ <%= render CommonComponents::MultiselectComponent.new(label: 'Select Organizations', target: 'tag[shared_organization_ids][]', options: @permitted_organizations.map { |o| {id: o.id, label: o.organization_name} },
+ selected_values: @tag.shared_organization_ids) %>
+ <%= render ValidationErrorComponent.new(model: @tag, key: :shared_organization_ids) %>
+
<%= f.label :shared, class: 'field-label' %>
<%= f.check_box :shared, class: 'h-4 w-4 ml-4 border-purple-500 text-green-600 focus:ring-green-600 cursor-pointer' %>
diff --git a/app/components/table_components/student_tag_row.html.erb b/app/components/table_components/student_tag_row.html.erb
index 54738d3ae..8cf111773 100644
--- a/app/components/table_components/student_tag_row.html.erb
+++ b/app/components/table_components/student_tag_row.html.erb
@@ -2,11 +2,11 @@
<%= @pagy.from + @item_counter %>
<%= @item.tag_name %>
- <%= @item.organization_name + (tag.shared ? " - #{t(:shared)}" : '') %>
+ <%= @item.organization_name + (@item.shared ? " - #{t(:shared)}" : '') %>
<%= @item.student_count %>
<% if can_update? %>
-
<%= t(:edit) %>
+
<%= t(:edit) %>
<% end %>
diff --git a/app/controllers/student_tags_controller.rb b/app/controllers/student_tags_controller.rb
index b91e658d6..60acae7ec 100644
--- a/app/controllers/student_tags_controller.rb
+++ b/app/controllers/student_tags_controller.rb
@@ -43,9 +43,14 @@ def create
def update
@tag = Tag.find params.require(:id)
authorize @tag
- return redirect_to student_tag_path(@tag) if @tag.update tag_params
- render :edit
+ if @tag.update tag_params
+ success title: t(:tag_updated), text: t(:tag_updated_text, tag: @tag.tag_name)
+ redirect_to student_tag_path(@tag)
+ else
+ failure title: t(:tag_invalid), text: t(:fix_form_errors)
+ render :edit, status: :unprocessable_content
+ end
end
def destroy
@@ -66,6 +71,10 @@ def destroy
private
def tag_params
- params.require(:tag).permit(:tag_name, :organization_id, :shared)
+ permitted = params.require(:tag).permit(:tag_name, :organization_id, :shared, shared_organization_ids: [])
+ permitted[:shared_organization_ids] ||= []
+ permitted[:shared_organization_ids] = permitted[:shared_organization_ids].compact_blank
+
+ permitted
end
end
diff --git a/app/javascript/controllers/multiselect_controller.js b/app/javascript/controllers/multiselect_controller.js
new file mode 100644
index 000000000..59dc317bf
--- /dev/null
+++ b/app/javascript/controllers/multiselect_controller.js
@@ -0,0 +1,91 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="multiselect"
+export default class extends Controller {
+ static targets = ["menu", "option", "hiddenField", "label"]
+ static values = { label: String, selectName: String }
+
+ connect() {
+ // Collect initial values from hidden inputs
+ this.selected = [...this.hiddenFieldTarget.querySelectorAll("input")].map(input => input.value)
+ this.applyInitialSelection()
+ this.updateLabel()
+
+ // Close menu if anything outside is clicked
+ this.outsideClick = this.handleClickOutside.bind(this)
+ document.addEventListener("click", this.outsideClick)
+ }
+
+ disconnect() {
+ document.removeEventListener("click", this.outsideClick)
+ }
+
+ handleClickOutside(event) {
+ if (!this.element.contains(event.target)) {
+ this.menuTarget.classList.add("hidden")
+ }
+ }
+
+ applyInitialSelection() {
+ this.optionTargets.forEach(opt => {
+ const id = opt.dataset.value
+
+ if (this.selected.includes(id)) {
+ opt.classList.add("text-green-600")
+ opt.querySelector(".checkmark").classList.remove("hidden")
+ }
+ })
+ }
+
+ toggleMenu(event) {
+ event.stopPropagation()
+ this.menuTarget.classList.toggle("hidden")
+ }
+
+ toggleOption(event) {
+ const item = event.currentTarget
+ const id = item.dataset.value
+
+ if (this.selected.includes(id)) {
+ this.selected = this.selected.filter(s => s !== id)
+ item.classList.remove("text-green-600")
+ item.querySelector(".checkmark").classList.add("hidden")
+ } else {
+ this.selected.push(id)
+ item.classList.add("text-green-600")
+ item.querySelector(".checkmark").classList.remove("hidden")
+ }
+
+ this.rebuildHiddenInputs()
+ this.updateLabel()
+ }
+
+ rebuildHiddenInputs() {
+ this.hiddenFieldTarget.innerHTML = ""
+
+ if(this.selected.length ===0){
+ const blank = document.createElement("input")
+ blank.type = "hidden"
+ blank.name = this.selectNameValue
+ blank.value = ''
+ this.hiddenFieldTarget.appendChild(blank)
+ return
+ }
+
+ this.selected.forEach(id => {
+ const input = document.createElement("input")
+ input.type = "hidden"
+ input.name = this.selectNameValue
+ input.value = id
+ this.hiddenFieldTarget.appendChild(input)
+ })
+ }
+
+ updateLabel() {
+ if (this.selected.length === 0) {
+ this.labelTarget.textContent = this.labelValue
+ } else {
+ this.labelTarget.textContent = `${this.selected.length} selected`
+ }
+ }
+}
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 3fac9d718..a5f67e803 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -2,12 +2,13 @@
#
# Table name: tags
#
-# id :uuid not null, primary key
-# shared :boolean not null
-# tag_name :string not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# organization_id :bigint not null
+# id :uuid not null, primary key
+# shared :boolean not null
+# shared_organization_ids :bigint default([]), is an Array
+# tag_name :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# organization_id :bigint not null
#
# Indexes
#
@@ -25,6 +26,16 @@ class Tag < ApplicationRecord
"Tag \"#{data[:value]}\" already exists in #{Organization.find(tag.organization_id).organization_name} organization"
end
}
+ validate :no_duplicate_shared_organization_ids
+ validate :shared_organization_ids_exist
+
+ def no_duplicate_shared_organization_ids
+ errors.add(:shared_organization_ids, I18n.t(:'errors.messages.must_contain_unique')) if shared_organization_ids.any? && shared_organization_ids.uniq.length != shared_organization_ids.length
+ end
+
+ def shared_organization_ids_exist
+ errors.add(:shared_organization_ids, I18n.t(:selected_organizations_must_exist)) if shared_organization_ids.any? { |id| !Organization.exists?(id) }
+ end
def can_delete?
students.none?
diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb
index 5978e8c74..3f1c60a45 100644
--- a/app/policies/tag_policy.rb
+++ b/app/policies/tag_policy.rb
@@ -19,12 +19,16 @@ def resolve
if user.global_role?
scope.all
else
- scope.where(organization_id: user.membership_organizations).or(scope.where(shared: true))
+ scope.where(organization_id: user.membership_organizations)
+ .or(scope.where(shared: true))
+ .or(scope.where('shared_organization_ids && ARRAY[?]::bigint[]', user.membership_organizations.map(&:id)))
end
end
def resolve_for_organization_id(organization_id)
- scope.where(organization_id:).or(scope.where(shared: true))
+ scope.where(organization_id:)
+ .or(scope.where(shared: true))
+ .or(scope.where('shared_organization_ids && ARRAY[?]::bigint[]', user.membership_organizations.map(&:id)))
end
end
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index fae101892..820c83aaf 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -36,6 +36,7 @@ en:
chapter_mlid_error_message: Chapter MLID has to be up to 2 characters long and can only contain numbers and letters. No special characters.
group_mlid_error_message: Group MLID has to be up to 2 characters long and can only contain numbers and letters. No special characters.
student_and_group_in_different_organizations: Student and Group are not in the same organization
+ selected_organizations_must_exist: Selected Organizations must exist
mlid_cannot_contain_special_characters: No special characters allowed in MLID
enrollments: Enrollments
enroll: Enroll
@@ -403,9 +404,12 @@ en:
tag_shared_explanation: Shared tags will be available to all organizations, rather than just the organization the tag belongs to.
tag_added: Tag Added.
tag_with_name_added: Tag "%{name}" added.
+ tag_updated: Tag Updated
+ tag_updated_text: Tag "%{tag}" updated.
delete_tag: Delete Tag
tag_deleted: Tag Deleted
tag_deleted_text: Tag "%{tag_name}" deleted.
+ tag_invalid: Tag Invalid
unable_to_delete_tag: Unable to delete tag
tag_not_deleted_because_students: Tag not deleted because it has students associated with it. Remove it from the students before deleting.
@@ -436,6 +440,7 @@ en:
messages:
extension_whitelist_error: File is not an image
invalid_email: is not valid
+ must_contain_unique: must contain only unique values
enums:
student:
diff --git a/db/migrate/20251118142453_add_shared_organization_ids_to_tags.rb b/db/migrate/20251118142453_add_shared_organization_ids_to_tags.rb
new file mode 100644
index 000000000..4d2794cb1
--- /dev/null
+++ b/db/migrate/20251118142453_add_shared_organization_ids_to_tags.rb
@@ -0,0 +1,5 @@
+class AddSharedOrganizationIdsToTags < ActiveRecord::Migration[7.2]
+ def change
+ add_column :tags, :shared_organization_ids, :bigint, array: true, default: []
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index a49951587..44685c7d4 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -1116,7 +1116,8 @@ CREATE TABLE public.tags (
organization_id bigint NOT NULL,
shared boolean NOT NULL,
created_at timestamp(6) without time zone NOT NULL,
- updated_at timestamp(6) without time zone NOT NULL
+ updated_at timestamp(6) without time zone NOT NULL,
+ shared_organization_ids bigint[] DEFAULT '{}'::bigint[]
);
@@ -2165,6 +2166,7 @@ ALTER TABLE ONLY public.users_roles
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
+('20251118142453'),
('20251030155718'),
('20251018160342'),
('20251009215138'),
diff --git a/spec/components/multiselect_component_spec.rb b/spec/components/multiselect_component_spec.rb
new file mode 100644
index 000000000..d0d3b0489
--- /dev/null
+++ b/spec/components/multiselect_component_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+RSpec.describe CommonComponents::MultiselectComponent, type: :component do
+ before :each do
+ @label = 'Select Items'
+ @options = [{ id: 1, label: 'First Item' }, { id: 2, label: 'Second Item' }, { id: 3, label: 'Third Item' }]
+ @target = 'some_target'
+ end
+
+ it 'has the correct label' do
+ render_inline(CommonComponents::MultiselectComponent.new(label: @label, target: @target, options: @options))
+ expect(page).to have_css('button', text: @label, visible: true)
+ end
+
+ it 'contains all options' do
+ render_inline(CommonComponents::MultiselectComponent.new(label: @label, target: @target, options: @options))
+ rendered_options = page.find_all('div[data-multiselect-target="option"] > span', visible: false)
+
+ @options.each_with_index do |option, index|
+ expect(rendered_options[index].text).to eq option[:label]
+ end
+ end
+
+ it 'renders inputs for already selected options' do
+ selected_values = [@options[0][:id], @options[1][:id]]
+ render_inline(CommonComponents::MultiselectComponent.new(label: @label, target: @target, options: @options, selected_values: selected_values))
+
+ hidden_inputs = page.find_all('div[data-multiselect-target="hiddenField"] > input', visible: false)
+
+ selected_values.each_with_index do |value, index|
+ expect(hidden_inputs[index].value).to eq value.to_s
+ end
+ end
+end
diff --git a/spec/factories/tags.rb b/spec/factories/tags.rb
index 38a8e5dbe..f21cb98f6 100644
--- a/spec/factories/tags.rb
+++ b/spec/factories/tags.rb
@@ -2,12 +2,13 @@
#
# Table name: tags
#
-# id :uuid not null, primary key
-# shared :boolean not null
-# tag_name :string not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# organization_id :bigint not null
+# id :uuid not null, primary key
+# shared :boolean not null
+# shared_organization_ids :bigint default([]), is an Array
+# tag_name :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# organization_id :bigint not null
#
# Indexes
#
diff --git a/spec/features/student_tag_features_spec.rb b/spec/features/student_tag_features_spec.rb
index 986d1a38b..7f9ce56d2 100644
--- a/spec/features/student_tag_features_spec.rb
+++ b/spec/features/student_tag_features_spec.rb
@@ -6,6 +6,7 @@
describe 'Create Tag' do
before :each do
@org = create :organization
+ @other_organizations = create_list :organization, 2
@tags = create_list :tag, 3, organization: @org
end
@@ -17,11 +18,15 @@
click_link 'Add Tag'
fill_in 'Tag name', with: 'My Test Tag'
select(@org.organization_name, from: 'Organization')
- find('label', text: 'Shared').click
+ find('button', text: 'Select Organizations').click
+ find('span', text: @other_organizations[0].organization_name).click
+
+ expect(page).to have_content '1 selected'
+
+ find('input[name="tag[shared]"]').click
click_button 'Create'
expect(page).to have_content 'Tag "My Test Tag" added'
-
expect(page).to have_content 'My Test Tag'
expect(page).to have_content @tags[0].tag_name
expect(page).to have_content @tags[1].tag_name
diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb
index a7e9ce36a..282c59468 100644
--- a/spec/models/tag_spec.rb
+++ b/spec/models/tag_spec.rb
@@ -2,12 +2,13 @@
#
# Table name: tags
#
-# id :uuid not null, primary key
-# shared :boolean not null
-# tag_name :string not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# organization_id :bigint not null
+# id :uuid not null, primary key
+# shared :boolean not null
+# shared_organization_ids :bigint default([]), is an Array
+# tag_name :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# organization_id :bigint not null
#
# Indexes
#
@@ -24,6 +25,7 @@
describe 'validations' do
it { should validate_presence_of :tag_name }
+
it 'when the same tag is created for an organization' do
@org = create :organization
create :tag, tag_name: 'Existing Tag', organization: @org
@@ -31,6 +33,22 @@
expect(new_tag).to_not be_valid
end
+
+ it 'when tag has duplicate shared organization ids' do
+ @organizations = create_list :organization, 3
+ new_tag = Tag.new tag_name: 'New Tag', organization_id: @organizations[0].id, shared_organization_ids: [@organizations[1].id, @organizations[2].id, @organizations[2].id]
+
+ expect(new_tag).to_not be_valid
+ expect(new_tag.errors[:shared_organization_ids]).to include I18n.t(:'errors.messages.must_contain_unique')
+ end
+
+ it 'when tag has duplicate shared organization ids' do
+ @org = create :organization
+ new_tag = Tag.new tag_name: 'New Tag', organization_id: @org.id, shared_organization_ids: [5]
+
+ expect(new_tag).to_not be_valid
+ expect(new_tag.errors[:shared_organization_ids]).to include I18n.t(:selected_organizations_must_exist)
+ end
end
describe 'methods' do
diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb
index 6e7dc24ed..6a3283088 100644
--- a/spec/policies/tag_policy_spec.rb
+++ b/spec/policies/tag_policy_spec.rb
@@ -208,12 +208,15 @@
let(:current_user) { create :admin_of, organization: org1 }
subject(:result) { TagPolicy::Scope.new(current_user, Tag).resolve_for_organization_id(org1.id) }
- it 'scopes to organization and shared tags' do
+ it 'scopes to organization and shared tags and tags with shared organization ids' do
shared_tags = create_list :tag, 3
org1_tag = create :tag, organization: org1, shared: false
org2_tag = create :tag, organization: org2, shared: false
+ shared_org2_tag = create :tag, organization: org2, shared: true, shared_organization_ids: [org1.id]
+
expect(result).to include(shared_tags[0], shared_tags[1], shared_tags[2])
expect(result).to include(org1_tag)
+ expect(result).to include(shared_org2_tag)
expect(result).not_to include(org2_tag)
end
end
@@ -224,13 +227,14 @@
let(:shared_tags) { create_list :tag, 3 }
let(:org1_tag) { create :tag, organization: org1, shared: false }
let(:org2_tag) { create :tag, organization: org2, shared: false }
+ let(:shared_org2_tag) { create :tag, organization: org2, shared: false, shared_organization_ids: [org1.id] }
subject(:result) { TagPolicy::Scope.new(current_user, Tag).resolve }
context 'user is a super administrator' do
let(:current_user) { create :super_admin }
it 'includes all tags' do
- expect(result).to include shared_tags[0], shared_tags[1], shared_tags[2], org1_tag, org2_tag
+ expect(result).to include shared_tags[0], shared_tags[1], shared_tags[2], org1_tag, org2_tag, shared_org2_tag
end
end
@@ -238,7 +242,7 @@
let(:current_user) { create :admin_of, organization: org1 }
it 'includes all tags' do
- expect(result).to include shared_tags[0], shared_tags[1], shared_tags[2], org1_tag
+ expect(result).to include shared_tags[0], shared_tags[1], shared_tags[2], org1_tag, shared_org2_tag
expect(result).not_to include org2_tag
end
end