Skip to content

Commit 7daa2dc

Browse files
authored
Feature tags step 1 (#4884)
* Fix model spec generation error * Add tags and taggings model/concern * Add uniqueness constraint on taggings and fix indexes * Add tagging related associations and scopes * Add tag and tagging model specs * Add tag filtering to product drives index page * Clean up by_tags scope * Fix factory_bot linting error * Merge with main * Add ability to tag product drives * Remove WIP controller * Fix factory bot lint error * Fix missing tags after validation failure * Add specs * Fix styling of product drive modal * Add tags_for_display method to Taggable concern * Fix name in tag factory * Refactor ProductDrivesController#update to be more consistent * Move by_type scope to Tag model * Move by_type scope spec to tag model * Order tags alphabetically in dropdowns * Move organization_id to tags table * Fix linting * Refactor filter by tags to shared example * Remove unnecessary scope * Fix wip changes * Add type column to tags table * Rename by_tags scope parameter to tag_names * Fix missing tags on failure bug * Fix lost tags on product drive update failure * Add request specs for preserving tags on validation failure * Set selectOnClose config value to true for product drive tags * Fix new tag disappearance on validation failure
1 parent 7ce6e5d commit 7daa2dc

22 files changed

+377
-13
lines changed

Gemfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ group :development, :test do
155155
# Rails add-on for static analysis.
156156
gem 'rubocop-performance'
157157
gem "rubocop-rails", "~> 2.25.1"
158+
# More concise test ("should") matchers
159+
gem "shoulda-matchers", "~> 6.2"
158160
# Default rules for Rubocop.
159161
gem "standard", "~> 1.40"
160162
gem "standard-rails"
@@ -197,8 +199,6 @@ group :test do
197199
gem "rails-controller-testing"
198200
# Show code coverage.
199201
gem 'simplecov'
200-
# More concise test ("should") matchers
201-
gem 'shoulda-matchers', '~> 6.2'
202202
# Mock HTTP requests and ensure they are not called during tests.
203203
gem "webmock", "~> 3.24"
204204
# Interface capybara to chrome headless

app/assets/stylesheets/application.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ select.selectpicker + .dropdown-toggle::after {
188188
background-color: #8282df
189189
}
190190

191+
// Fix select2 tagging search box to be inline with tags.
192+
// This is the default for select2 but adminLTE overwrites it.
193+
.select2-search.select2-search--inline {
194+
display: inline-block;
195+
}
196+
191197
div.low_priority_warning {
192198
margin: 5px;
193199
text-align: center;

app/controllers/product_drives_controller.rb

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
class ProductDrivesController < ApplicationController
22
include Importable
33
before_action :set_product_drive, only: [:show, :edit, :update, :destroy]
4+
before_action :set_tags, only: [:index, :new, :edit]
45

56
def index
67
setup_date_range_picker
78
@product_drives = current_organization
89
.product_drives
9-
.includes(donations: {line_items: :item})
10+
.includes(:tags, donations: {line_items: :item})
1011
.class_filter(filter_params)
1112
.within_date_range(@selected_date_range)
1213
.order(start_date: :desc)
@@ -15,6 +16,7 @@ def index
1516
@item_categories = current_organization.item_categories
1617
@selected_name_filter = filter_params[:by_name]
1718
@selected_item_category = filter_params[:by_item_category_id]
19+
@selected_tags = filter_params[:by_tags]
1820

1921
respond_to do |format|
2022
format.html
@@ -32,12 +34,14 @@ def index
3234
# GET /product_drives/1.json
3335

3436
def create
35-
@product_drive = current_organization.product_drives.new(product_drive_params.merge(organization: current_organization))
37+
@product_drive = current_organization.product_drives.new(product_drive_params)
38+
@product_drive.tags = tags_from_params
3639
respond_to do |format|
3740
if @product_drive.save
3841
format.html { redirect_to product_drives_path, notice: "New product drive added!" }
3942
format.js
4043
else
44+
set_tags
4145
flash.now[:error] = "Something didn't work quite right -- try again?"
4246
format.html { render action: :new }
4347
format.js { render template: "product_drives/new_modal" }
@@ -66,10 +70,11 @@ def show
6670

6771
def update
6872
@product_drive = current_organization.product_drives.find(params[:id])
73+
@product_drive.tags = tags_from_params
6974
if @product_drive.update(product_drive_params)
7075
redirect_to product_drives_path, notice: "#{@product_drive.name} updated!"
71-
7276
else
77+
set_tags
7378
flash.now[:error] = "Something didn't work quite right -- try again?"
7479
render action: :edit
7580
end
@@ -90,9 +95,24 @@ def set_product_drive
9095
@product_drive_info = ProductDrive.find(params[:id])
9196
end
9297

98+
def set_tags
99+
@tags = current_organization.product_drive_tags.alphabetized.select(:id, :name)
100+
end
101+
93102
def product_drive_params
94103
params.require(:product_drive)
95-
.permit(:name, :start_date, :end_date, :virtual)
104+
.permit(:name, :start_date, :end_date, :virtual, tags: [])
105+
.except(:tags)
106+
end
107+
108+
def tags_from_params
109+
tag_names = params[:product_drive][:tags]
110+
return [] if tag_names.blank?
111+
112+
tag_names
113+
.compact_blank
114+
.uniq
115+
.map { |name| Tag.find_or_create_by(name:, type: "ProductDrive", organization: current_organization) }
96116
end
97117

98118
def date_range_filter
@@ -105,6 +125,6 @@ def date_range_filter
105125
def filter_params
106126
return {} unless params.key?(:filters)
107127

108-
params.require(:filters).permit(:by_name, :by_item_category_id)
128+
params.require(:filters).permit(:by_name, :by_item_category_id, :by_tags)
109129
end
110130
end

app/models/concerns/taggable.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module Taggable
2+
extend ActiveSupport::Concern
3+
4+
included do
5+
has_many :taggings, as: :taggable, dependent: :destroy
6+
has_many :tags, through: :taggings, source: :tag
7+
8+
scope :by_tags, ->(tag_names) { left_joins(:tags).where(tags: {name: tag_names}) }
9+
10+
accepts_nested_attributes_for :taggings, :tags
11+
12+
def tags_for_display
13+
tags.map(&:name).sort.join(", ")
14+
end
15+
end
16+
end

app/models/organization.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ class Organization < ApplicationRecord
7171
has_many :purchases
7272
has_many :requests
7373
has_many :storage_locations
74+
has_many :tags
75+
has_many :product_drive_tags, -> { by_type("ProductDrive") },
76+
class_name: "Tag", inverse_of: false
7477
has_many :inventory_items, through: :storage_locations
7578
has_many :kits
7679
has_many :transfers

app/models/product_drive.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class ProductDrive < ApplicationRecord
1616
has_paper_trail
1717
belongs_to :organization, optional: true
1818
include Filterable
19+
include Taggable
1920

2021
scope :by_name, ->(name_filter) { where(name: name_filter) }
2122
scope :by_item_category_id, ->(item_category_id) {
@@ -44,7 +45,7 @@ def end_date_is_bigger_of_end_date
4445
return if start_date.nil? || end_date.nil?
4546

4647
if end_date < start_date
47-
errors.add(:end_date, 'End date must be after the start date')
48+
errors.add(:end_date, 'must be after the start date')
4849
end
4950
end
5051

app/models/tag.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# == Schema Information
2+
#
3+
# Table name: tags
4+
#
5+
# id :bigint not null, primary key
6+
# name :string(256) not null
7+
# type :string not null
8+
# created_at :datetime not null
9+
# updated_at :datetime not null
10+
# organization_id :bigint not null
11+
#
12+
class Tag < ApplicationRecord
13+
self.inheritance_column = nil
14+
15+
has_many :taggings, dependent: :destroy
16+
belongs_to :organization
17+
18+
scope :alphabetized, -> { order(:name) }
19+
scope :by_type, ->(type) { where(type: type) }
20+
21+
validates :name, presence: true, length: {maximum: 256}
22+
validates :name, uniqueness: {scope: [:type, :organization_id]}
23+
end

app/models/tagging.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# == Schema Information
2+
#
3+
# Table name: taggings
4+
#
5+
# id :bigint not null, primary key
6+
# taggable_type :string not null
7+
# created_at :datetime not null
8+
# updated_at :datetime not null
9+
# tag_id :bigint not null
10+
# taggable_id :bigint not null
11+
#
12+
class Tagging < ApplicationRecord
13+
belongs_to :tag
14+
belongs_to :taggable, polymorphic: true
15+
16+
validates :tag_id, uniqueness: {scope: :taggable, message: "has already been applied"}
17+
end

app/views/product_drives/_form.html.erb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,18 @@
2424
as: :date,
2525
html5: true %>
2626
</div>
27-
27+
<div class="form-group">
28+
<%= f.input :tags,
29+
collection: @tags,
30+
value_method: :name,
31+
multiple: true,
32+
selected: @product_drive.tags.map(&:name),
33+
wrapper: :input_group,
34+
input_html: {
35+
"data-controller": "select2",
36+
"data-select2-config-value": '{"selectOnClose": "true", "width": "100%", "placeholder":"Add a tag", "tags": "true", "tokenSeparators": [",", "\t"]}'
37+
} %>
38+
</div>
2839
<div class="form-group">
2940
<%= f.input :virtual, label: "Product Drive is Virtual?", wrapper: :input_group do %>
3041
<%= f.check_box :virtual, {class: "input-group-text", id: "virtual"}, "true" %>

app/views/product_drives/index.html.erb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
<div class="form-group col-lg-3 col-md-4 col-sm-6 col-xs-12">
4444
<%= filter_select(label: "Filter by item category", scope: :by_item_category_id, collection: @item_categories, selected: @selected_item_category) %>
4545
</div>
46+
<div class="form-group col-lg-3 col-md-4 col-sm-6 col-xs-12">
47+
<%= filter_select(label: "Filter by tag", scope: :by_tags, key: :name, collection: @tags, selected: @selected_tags) %>
48+
</div>
4649
<div class="form-group col-lg-3 col-md-4 col-sm-6 col-xs-12">
4750
<%= label_tag "Date Range" %>
4851
<%= render partial: "shared/date_range_picker", locals: {css_class: "form-control"} %>
@@ -90,6 +93,7 @@
9093
<th>Product Drive Name</th>
9194
<th>Start Date</th>
9295
<th>End Date</th>
96+
<th>Tags</th>
9397
<th>Held Virtually?</th>
9498
<th class="numeric">Quantity of Items</th>
9599
<th class="numeric">Variety of Items</th>
@@ -103,6 +107,7 @@
103107
<td><%= product_drive.name %></td>
104108
<td><%= product_drive.start_date.strftime("%m-%d-%Y") %></td>
105109
<td><%= product_drive.end_date&.strftime("%m-%d-%Y") %></td>
110+
<td><%= product_drive.tags_for_display %></td>
106111
<td><%= is_virtual(product_drive: product_drive) %></td>
107112
<td class="text-right"><%= product_drive.donation_quantity_by_date(selected_range, @selected_item_category) %></td>
108113
<td class="text-right"><%= product_drive.distinct_items_count_by_date(selected_range, @selected_item_category) %></td>

0 commit comments

Comments
 (0)