Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## Unreleased
- Added ability to share tags only to certain organizations
- Extended importing students to handle all student fields
- Added alert when viewing a group with students graded outside their enrollment
- Improved speed when querying students which have no active enrollments
Expand Down
3 changes: 3 additions & 0 deletions app/assets/images/checkmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions app/components/common_components/multiselect_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class CommonComponents::MultiselectComponent < ViewComponent::Base
erb_template <<~ERB
<div data-controller="multiselect" data-multiselect-label-value="<%= @label %>" data-multiselect-select-name-value="<%= @target %>" class="col-span-6 lg:col-span-2 relative">
<!-- Hidden inputs for each selected item from the menu-->
<div data-multiselect-target="hiddenField">
<% @selected_values&.each do |value| %>
<input type="hidden" name="<%= @target %>" value="<%= value %>">
<% end %>
</div>

<button type="button" data-action="click->multiselect#toggleMenu"
class="w-full border border-purple-500 p-2 rounded-md bg-white flex justify-between items-center text-md sm:text-sm font-medium focus:border-green-600 focus:ring-green-600">
<span data-multiselect-target="label" > <%= @label %> </span>
<%= helpers.inline_svg_tag("arrow_down.svg", class: "w-5 h-5 fill-gray-500") %>
</button>
<!-- Dropdown Menu -->
<div class="absolute p-1 w-full border rounded-md bg-white hidden z-10" data-multiselect-target="menu">
<% @options.each do |option| %>
<div class="hover:bg-gray-100 cursor-pointer flex justify-between p-2 rounded-md flex justify-between items-center text-md sm:text-sm font-medium"
data-value="<%= option[:id] %>"
data-action="click->multiselect#toggleOption"
data-multiselect-target="option">
<span><%= option[:label] %></span>
<%= helpers.inline_svg_tag("checkmark.svg", class: "w-4 h-4 text-green-600 checkmark hidden") %>
</div>
<% end %>
</div>
</div>
ERB

def initialize(label:, target:, options:, selected_values: nil)
@label = label
@target = target
@options = options
@selected_values = selected_values
end
end
11 changes: 10 additions & 1 deletion app/components/student_tag_form_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@
<h3 class="text-lg font-medium leading-6 text-gray-900"><%= t(:tag_information) %></h3>
</div>
<div class="mt-5 md:col-span-3 md:mt-0">
<div class="grid grid-cols-6 gap-4">
<div class="grid grid-cols-8 gap-4">
<div class="col-span-6 lg:col-span-2">
<%= 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) %>
</div>

<div class="col-span-6 lg:col-span-2">
<%= 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) %>
</div>

<div class="col-span-6 lg:col-span-2">
<%= 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) %>
</div>

<div class="col-span-6 lg:col-span-2">
<%= 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' %>
Expand Down
4 changes: 2 additions & 2 deletions app/components/table_components/student_tag_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
<a href="<%= helpers.student_tag_path @item %>" class="<%= 'shaded-row' if shaded? %> table-row-wrapper">
<div class="table-cell "><%= @pagy.from + @item_counter %></div>
<div class="text-right table-cell "><%= @item.tag_name %></div>
<div class="text-right table-cell truncate "><%= @item.organization_name + (tag.shared ? " - #{t(:shared)}" : '') %></div>
<div class="text-right table-cell truncate "><%= @item.organization_name + (@item.shared ? " - #{t(:shared)}" : '') %></div>
<div class="table-cell "><%= @item.student_count %></div>
<div class="text-right table-cell ">
<% if can_update? %>
<a id="edit-button-<%= @item_counter %>" class="table-action-link" href="<%= helpers.edit_student_tag_path @item %>}"><%= t(:edit) %></a>
<a id="edit-button-<%= @item_counter %>" class="table-action-link" href="<%= helpers.edit_student_tag_path @item %>"><%= t(:edit) %></a>
<% end %>
</div>
</a>
Expand Down
15 changes: 12 additions & 3 deletions app/controllers/student_tags_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
91 changes: 91 additions & 0 deletions app/javascript/controllers/multiselect_controller.js
Original file line number Diff line number Diff line change
@@ -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`
}
}
}
23 changes: 17 additions & 6 deletions app/models/tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -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?
Expand Down
8 changes: 6 additions & 2 deletions app/policies/tag_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddSharedOrganizationIdsToTags < ActiveRecord::Migration[7.2]
def change
add_column :tags, :shared_organization_ids, :bigint, array: true, default: []
end
end
4 changes: 3 additions & 1 deletion db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
);


Expand Down Expand Up @@ -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'),
Expand Down
34 changes: 34 additions & 0 deletions spec/components/multiselect_component_spec.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 7 additions & 6 deletions spec/factories/tags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
Loading
Loading