Skip to content

Commit 426f6b0

Browse files
committed
add tagging
1 parent 4b77948 commit 426f6b0

24 files changed

Lines changed: 559 additions & 4 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
module Admin
2+
class AgendaItemTagsController < BaseController
3+
before_action :set_agenda_item
4+
5+
def create
6+
tag = Tag.find_or_create_by!(name: params[:tag_name].to_s.strip)
7+
@agenda_item.agenda_item_tags.find_or_create_by!(tag: tag)
8+
9+
render_tags_stream
10+
end
11+
12+
def destroy
13+
agenda_item_tag = @agenda_item.agenda_item_tags.find(params[:id])
14+
agenda_item_tag.destroy!
15+
16+
render_tags_stream
17+
end
18+
19+
private
20+
21+
def set_agenda_item
22+
@agenda_item = AgendaItem.find(params[:agenda_item_id])
23+
end
24+
25+
def render_tags_stream
26+
@agenda_item.reload
27+
render turbo_stream: turbo_stream.replace(
28+
"agenda_item_#{@agenda_item.id}_tags",
29+
partial: "admin/agenda_item_tags/tags",
30+
locals: { agenda_item: @agenda_item }
31+
)
32+
end
33+
end
34+
end

app/controllers/admin/meetings_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ def index
55
end
66

77
def show
8-
@meeting = Meeting.includes(agenda_versions: { agenda_sections: :agenda_items }).find(params[:id])
8+
@meeting = Meeting.includes(agenda_versions: { agenda_sections: { agenda_items: :tags } }).find(params[:id])
99
@agenda_version = if params[:version].present?
1010
@meeting.version(params[:version])
1111
else
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module Admin
2+
class TagsController < BaseController
3+
def search
4+
tags = Tag.search(params[:q].to_s).alphabetical.limit(10)
5+
render json: tags.map { |t| { id: t.id, name: t.name } }
6+
end
7+
end
8+
end

app/controllers/council_members_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def index
1313
def show
1414
@council_member = CouncilMember.find(params[:id])
1515
@votes = @council_member.votes
16-
.eager_load(agenda_item: { agenda_section: { agenda_version: :meeting } })
16+
.eager_load(agenda_item: [ :tags, { agenda_section: { agenda_version: :meeting } } ])
1717
.order("meetings.date DESC, agenda_items.item_number ASC")
1818
end
1919
end

app/controllers/meetings_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ def index
66
end
77

88
def show
9-
@meeting = Meeting.includes(agenda_versions: { agenda_sections: { agenda_items: { votes: :council_member } } }).find(params[:id])
9+
@meeting = Meeting.includes(agenda_versions: { agenda_sections: { agenda_items: [ :tags, { votes: :council_member } ] } }).find(params[:id])
1010
@agenda_version = if params[:version].present?
1111
@meeting.version(params[:version])
1212
else
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["input", "dropdown", "container"]
5+
static values = {
6+
searchUrl: String,
7+
createUrl: String,
8+
agendaItemId: Number
9+
}
10+
11+
connect() {
12+
this.selectedIndex = -1
13+
this.debounceTimer = null
14+
this.handleClickOutside = this.handleClickOutside.bind(this)
15+
document.addEventListener("click", this.handleClickOutside)
16+
}
17+
18+
disconnect() {
19+
document.removeEventListener("click", this.handleClickOutside)
20+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
21+
}
22+
23+
onInput() {
24+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
25+
this.debounceTimer = setTimeout(() => this.search(), 200)
26+
}
27+
28+
onKeydown(event) {
29+
const items = this.dropdownTarget.querySelectorAll("li")
30+
31+
switch (event.key) {
32+
case "ArrowDown":
33+
event.preventDefault()
34+
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1)
35+
this.highlightItem(items)
36+
break
37+
case "ArrowUp":
38+
event.preventDefault()
39+
this.selectedIndex = Math.max(this.selectedIndex - 1, 0)
40+
this.highlightItem(items)
41+
break
42+
case "Enter":
43+
event.preventDefault()
44+
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
45+
items[this.selectedIndex].click()
46+
}
47+
break
48+
case "Escape":
49+
this.hideDropdown()
50+
break
51+
}
52+
}
53+
54+
async search() {
55+
const query = this.inputTarget.value.trim()
56+
if (query.length === 0) {
57+
this.hideDropdown()
58+
return
59+
}
60+
61+
const response = await fetch(`${this.searchUrlValue}?q=${encodeURIComponent(query)}`)
62+
const tags = await response.json()
63+
64+
this.renderDropdown(tags, query)
65+
}
66+
67+
renderDropdown(tags, query) {
68+
this.dropdownTarget.innerHTML = ""
69+
this.selectedIndex = -1
70+
71+
const exactMatch = tags.some(t => t.name.toLowerCase() === query.toLowerCase())
72+
73+
tags.forEach(tag => {
74+
const li = document.createElement("li")
75+
li.textContent = tag.name
76+
li.className = "px-3 py-1.5 cursor-pointer hover:bg-indigo-50"
77+
li.addEventListener("click", () => this.selectTag(tag.name))
78+
this.dropdownTarget.appendChild(li)
79+
})
80+
81+
if (!exactMatch && query.length > 0) {
82+
const li = document.createElement("li")
83+
li.innerHTML = `Create <em>${this.escapeHtml(query)}</em>`
84+
li.className = "px-3 py-1.5 cursor-pointer hover:bg-indigo-50 text-indigo-600 border-t border-gray-100"
85+
li.addEventListener("click", () => this.selectTag(query))
86+
this.dropdownTarget.appendChild(li)
87+
}
88+
89+
if (this.dropdownTarget.children.length > 0) {
90+
this.showDropdown()
91+
} else {
92+
this.hideDropdown()
93+
}
94+
}
95+
96+
highlightItem(items) {
97+
items.forEach((item, index) => {
98+
item.classList.toggle("bg-indigo-50", index === this.selectedIndex)
99+
})
100+
}
101+
102+
async selectTag(tagName) {
103+
this.hideDropdown()
104+
this.inputTarget.value = ""
105+
106+
const formData = new FormData()
107+
formData.append("tag_name", tagName)
108+
formData.append("agenda_item_id", this.agendaItemIdValue)
109+
110+
const response = await fetch(this.createUrlValue, {
111+
method: "POST",
112+
headers: {
113+
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
114+
"Accept": "text/vnd.turbo-stream.html"
115+
},
116+
body: formData
117+
})
118+
119+
if (response.ok) {
120+
const html = await response.text()
121+
window.Turbo.renderStreamMessage(html)
122+
}
123+
}
124+
125+
showDropdown() {
126+
this.dropdownTarget.classList.remove("hidden")
127+
}
128+
129+
hideDropdown() {
130+
this.dropdownTarget.classList.add("hidden")
131+
this.selectedIndex = -1
132+
}
133+
134+
handleClickOutside(event) {
135+
if (this.hasContainerTarget && !this.containerTarget.contains(event.target)) {
136+
this.hideDropdown()
137+
}
138+
}
139+
140+
escapeHtml(text) {
141+
const div = document.createElement("div")
142+
div.textContent = text
143+
return div.innerHTML
144+
}
145+
}

app/models/agenda_item.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ class AgendaItem < ApplicationRecord
55
has_one :agenda_version, through: :agenda_section
66
has_one :meeting, through: :agenda_version
77
has_many :votes, dependent: :destroy
8+
has_many :agenda_item_tags, dependent: :destroy
9+
has_many :tags, through: :agenda_item_tags
810

911
enum :item_type, {
1012
ordinance: "ordinance",

app/models/agenda_item_tag.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class AgendaItemTag < ApplicationRecord
2+
belongs_to :agenda_item
3+
belongs_to :tag
4+
5+
validates :tag_id, uniqueness: { scope: :agenda_item_id }
6+
end

app/models/tag.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class Tag < ApplicationRecord
2+
has_many :agenda_item_tags, dependent: :destroy
3+
has_many :agenda_items, through: :agenda_item_tags
4+
5+
validates :name, presence: true, uniqueness: { case_sensitive: false }
6+
7+
normalizes :name, with: ->(name) { name.strip }
8+
9+
scope :search, ->(query) { where("LOWER(name) LIKE ?", "%#{query.downcase}%") }
10+
scope :alphabetical, -> { order(Arel.sql("LOWER(name)")) }
11+
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<div id="agenda_item_<%= agenda_item.id %>_tags"
2+
data-controller="tag-typeahead"
3+
data-tag-typeahead-search-url-value="<%= search_admin_tags_path %>"
4+
data-tag-typeahead-create-url-value="<%= admin_agenda_item_tags_path %>"
5+
data-tag-typeahead-agenda-item-id-value="<%= agenda_item.id %>"
6+
class="flex flex-wrap items-center gap-1.5">
7+
<% agenda_item.tags.alphabetical.each do |tag| %>
8+
<span class="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
9+
<%= tag.name %>
10+
<%= button_to admin_agenda_item_tag_path(agenda_item.agenda_item_tags.find_by(tag: tag), agenda_item_id: agenda_item.id),
11+
method: :delete,
12+
class: "ml-0.5 text-indigo-600 hover:text-indigo-900 cursor-pointer",
13+
data: { turbo_stream: true } do %>
14+
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
15+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
16+
</svg>
17+
<% end %>
18+
</span>
19+
<% end %>
20+
21+
<div class="relative" data-tag-typeahead-target="container">
22+
<input type="text"
23+
data-tag-typeahead-target="input"
24+
data-action="input->tag-typeahead#onInput keydown->tag-typeahead#onKeydown"
25+
placeholder="Add tag..."
26+
class="px-2 py-0.5 text-xs border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 w-24"
27+
autocomplete="off" />
28+
<ul data-tag-typeahead-target="dropdown"
29+
class="hidden absolute z-10 mt-1 w-48 bg-white border border-gray-200 rounded-md shadow-lg max-h-40 overflow-auto text-xs">
30+
</ul>
31+
</div>
32+
</div>

0 commit comments

Comments
 (0)