From 8ac1b699549314aaa287ad1ac38cb6701ae03554 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Tue, 18 Nov 2025 22:55:05 +0100 Subject: [PATCH 1/9] feat: added shared_organization_ids to tags --- app/models/tag.rb | 23 ++++++++++++++----- ...453_add_shared_organization_ids_to_tags.rb | 5 ++++ db/structure.sql | 4 +++- spec/factories/tags.rb | 13 ++++++----- spec/models/tag_spec.rb | 13 ++++++----- 5 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 db/migrate/20251118142453_add_shared_organization_ids_to_tags.rb diff --git a/app/models/tag.rb b/app/models/tag.rb index 3fac9d718..2c6f26883 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, 'must contain only unique values') 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, 'Organization selected must exist') if shared_organization_ids.any? { |id| !Organization.exists?(id) } + end def can_delete? students.none? 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/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/models/tag_spec.rb b/spec/models/tag_spec.rb index a7e9ce36a..3689685f9 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 # From ab645448944205ed3a103e416d3f0a2affe7317d Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Tue, 18 Nov 2025 22:56:04 +0100 Subject: [PATCH 2/9] feat: added multiselect component and stimulus controller for the same --- .../multiselect_component.rb | 38 ++++++++ .../controllers/multiselect_controller.js | 91 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 app/components/common_components/multiselect_component.rb create mode 100644 app/javascript/controllers/multiselect_controller.js diff --git a/app/components/common_components/multiselect_component.rb b/app/components/common_components/multiselect_component.rb new file mode 100644 index 000000000..9311a184c --- /dev/null +++ b/app/components/common_components/multiselect_component.rb @@ -0,0 +1,38 @@ +class CommonComponents::MultiselectComponent < ViewComponent::Base + + erb_template <<~ERB +
+ +
+ <% @selected_values.each do |value| %> + + <% end %> +
+ + + + +
+ ERB + + def initialize(label:, target:, options:, selected_values: nil) + @label = label + @target = target + @options = options + @selected_values = selected_values + 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` + } + } +} From b41ee6035cddd49f20169083a739bce714a1147d Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Tue, 18 Nov 2025 22:57:14 +0100 Subject: [PATCH 3/9] feat: added checkmark svg and locales, modified student tag form to use multiselect component for new field, modified controller --- app/assets/images/checkmark.svg | 3 +++ app/components/student_tag_form_component.html.erb | 11 ++++++++++- .../table_components/student_tag_row.html.erb | 4 ++-- app/controllers/student_tags_controller.rb | 14 +++++++++++--- config/locales/en.yml | 3 +++ 5 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 app/assets/images/checkmark.svg diff --git a/app/assets/images/checkmark.svg b/app/assets/images/checkmark.svg new file mode 100644 index 000000000..93e35e7b7 --- /dev/null +++ b/app/assets/images/checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/student_tag_form_component.html.erb b/app/components/student_tag_form_component.html.erb index 816ee1e30..3709e003b 100644 --- a/app/components/student_tag_form_component.html.erb +++ b/app/components/student_tag_form_component.html.erb @@ -5,17 +5,26 @@

<%= t(:tag_information) %>

-
+
<%= 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 %>
diff --git a/app/controllers/student_tags_controller.rb b/app/controllers/student_tags_controller.rb index b91e658d6..2d2612cf0 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,9 @@ 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].compact_blank + + permitted end end diff --git a/config/locales/en.yml b/config/locales/en.yml index fae101892..33b5bfd96 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -403,9 +403,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. From 510cb74a79315b045c228632da8a93787bdc31e2 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Tue, 18 Nov 2025 23:04:12 +0100 Subject: [PATCH 4/9] chore: rubocop --- app/components/common_components/multiselect_component.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/common_components/multiselect_component.rb b/app/components/common_components/multiselect_component.rb index 9311a184c..67e444f3c 100644 --- a/app/components/common_components/multiselect_component.rb +++ b/app/components/common_components/multiselect_component.rb @@ -1,5 +1,4 @@ class CommonComponents::MultiselectComponent < ViewComponent::Base - erb_template <<~ERB
From cc71688f4edd0c26099a4e5b7e509541c48563d3 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Fri, 21 Nov 2025 11:07:13 +0100 Subject: [PATCH 5/9] feat: clean shared_organization_ids, update policy to check for shared ids --- app/controllers/student_tags_controller.rb | 1 + app/policies/tag_policy.rb | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/controllers/student_tags_controller.rb b/app/controllers/student_tags_controller.rb index 2d2612cf0..60acae7ec 100644 --- a/app/controllers/student_tags_controller.rb +++ b/app/controllers/student_tags_controller.rb @@ -72,6 +72,7 @@ def destroy def tag_params 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 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 From 43dd8bfa1b3748d1957ea6eccaa28c5e1bb0bb29 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Fri, 21 Nov 2025 11:07:34 +0100 Subject: [PATCH 6/9] spec: update tag policy scope specs --- spec/policies/tag_policy_spec.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 From cdb1119d25ff926e4a17db1f4df02b9b08c6caa6 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Fri, 21 Nov 2025 16:57:03 +0100 Subject: [PATCH 7/9] spec: modified failing spec, added specs for new component, missing null check --- .../multiselect_component.rb | 2 +- spec/components/multiselect_component_spec.rb | 34 +++++++++++++++++++ spec/features/student_tag_features_spec.rb | 9 +++-- 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 spec/components/multiselect_component_spec.rb diff --git a/app/components/common_components/multiselect_component.rb b/app/components/common_components/multiselect_component.rb index 67e444f3c..82e52a33d 100644 --- a/app/components/common_components/multiselect_component.rb +++ b/app/components/common_components/multiselect_component.rb @@ -3,7 +3,7 @@ class CommonComponents::MultiselectComponent < ViewComponent::Base
- <% @selected_values.each do |value| %> + <% @selected_values&.each do |value| %> <% end %>
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/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 From e29d508b5e309deed4b2d78f88f776e536557826 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Fri, 21 Nov 2025 17:15:40 +0100 Subject: [PATCH 8/9] spec: added validation specs and modified error messages --- app/models/tag.rb | 4 ++-- config/locales/en.yml | 2 ++ spec/models/tag_spec.rb | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/models/tag.rb b/app/models/tag.rb index 2c6f26883..a5f67e803 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -30,11 +30,11 @@ class Tag < ApplicationRecord validate :shared_organization_ids_exist def no_duplicate_shared_organization_ids - errors.add(:shared_organization_ids, 'must contain only unique values') if shared_organization_ids.any? && shared_organization_ids.uniq.length != shared_organization_ids.length + 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, 'Organization selected must exist') if shared_organization_ids.any? { |id| !Organization.exists?(id) } + errors.add(:shared_organization_ids, I18n.t(:selected_organizations_must_exist)) if shared_organization_ids.any? { |id| !Organization.exists?(id) } end def can_delete? diff --git a/config/locales/en.yml b/config/locales/en.yml index 33b5bfd96..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 @@ -439,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/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 3689685f9..282c59468 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -25,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 @@ -32,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 From 8274d7950eed3af3ce0c46da8c1236e3425bdeb7 Mon Sep 17 00:00:00 2001 From: KralMarko123 Date: Fri, 21 Nov 2025 17:22:42 +0100 Subject: [PATCH 9/9] chore: add to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b7be034..88456423a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## Unreleased +- Added ability to share tags only to certain organizations - Added alert when viewing a group with students graded outside their enrollment - Improved speed when querying students which have no active enrollments - Removed 'Created' and added 'Enrolled Since' column to student table in group view