Skip to content

Commit 896877d

Browse files
committed
tasks can be searched for
1 parent 5773a5b commit 896877d

File tree

4 files changed

+229
-2
lines changed

4 files changed

+229
-2
lines changed

app/controllers/maintenance_tasks/application_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@ class ApplicationController < MaintenanceTasks.parent_controller.constantize
1111
policy.style_src_elem(
1212
BULMA_CDN,
1313
# <style> tag in app/views/layouts/maintenance_tasks/application.html.erb
14-
"'sha256-bPoBNSY4OPj4PVy3JR+57IqkZNesZ1sKbHQAhkSThxQ='",
14+
"'sha256-Cb+Ml3lY17UKzW2LeTuFDi7IgPXk0ICA4TLWXd7mhAA='",
1515
)
1616
capybara_lockstep_scripts = [
1717
"'sha256-1AoN3ZtJC5OvqkMgrYvhZjp4kI8QjJjO7TAyKYiDw+U='",
1818
"'sha256-QVSzZi6ZsX/cu4h+hIs1iVivG1BxUmJggiEsGDIXBG0='", # with debug on
1919
] if defined?(Capybara::Lockstep)
2020
policy.script_src_elem(
2121
# <script> tag in app/views/layouts/maintenance_tasks/application.html.erb
22-
"'sha256-NiHKryHWudRC2IteTqmY9v1VkaDUA/5jhgXkMTkgo2w='",
22+
"'sha256-n0UyWNeUyfUfrkvN/G1LqwSiN8WTlXTbA2BCEPmtKrQ='",
2323
# <script> tag for capybara-lockstep
2424
*capybara_lockstep_scripts,
2525
)

app/views/layouts/maintenance_tasks/_navbar.html.erb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,19 @@
22
<div class="navbar-brand">
33
<%= link_to 'Maintenance Tasks', root_path, class: 'navbar-item is-size-4 has-text-weight-semibold has-text-danger' %>
44
</div>
5+
<% if action_name == "index" %>
6+
<div class="navbar-end">
7+
<div class="navbar-item">
8+
<div class="task-search" data-tasks-path="<%= tasks_path %>">
9+
<%= text_field_tag :task_search, nil,
10+
id: "task-search-input",
11+
class: "input",
12+
placeholder: "Search tasks...",
13+
autocomplete: "off",
14+
aria: { label: "Search tasks", autocomplete: "list", controls: "task-search-results" } %>
15+
<div class="task-search-results" id="task-search-results" role="listbox"></div>
16+
</div>
17+
</div>
18+
</div>
19+
<% end %>
520
</nav>

app/views/layouts/maintenance_tasks/application.html.erb

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,58 @@
123123
padding-left: 0.5rem;
124124
border-left: 1px solid #dbdbdb;
125125
}
126+
127+
/* Task search styles */
128+
.task-search {
129+
position: relative;
130+
width: 300px;
131+
}
132+
.task-search-results {
133+
position: absolute;
134+
top: 100%;
135+
left: 0;
136+
right: 0;
137+
background: #fff;
138+
border: 1px solid #dbdbdb;
139+
border-radius: 4px;
140+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
141+
max-height: 400px;
142+
overflow-y: auto;
143+
z-index: 100;
144+
display: none;
145+
}
146+
.task-search-results.is-active {
147+
display: block;
148+
}
149+
.task-search-results a {
150+
display: block;
151+
padding: 0.75rem 1rem;
152+
color: #363636;
153+
border-bottom: 1px solid #f5f5f5;
154+
text-decoration: none;
155+
}
156+
.task-search-results a:last-child {
157+
border-bottom: none;
158+
}
159+
.task-search-results a:hover,
160+
.task-search-results a.is-selected {
161+
background-color: #485fc7;
162+
color: #fff;
163+
}
164+
.task-search-results .no-results {
165+
padding: 0.75rem 1rem;
166+
color: #7a7a7a;
167+
font-style: italic;
168+
}
169+
.task-search-results mark {
170+
background-color: #ffe08a;
171+
color: inherit;
172+
padding: 0;
173+
}
174+
.task-search-results a:hover mark,
175+
.task-search-results a.is-selected mark {
176+
background-color: rgba(255,255,255,0.3);
177+
}
126178
</style>
127179

128180
<script>
@@ -147,6 +199,141 @@
147199
}, 3000)
148200
}
149201
document.addEventListener('DOMContentLoaded', refresh)
202+
203+
// Task search functionality
204+
function initTaskSearch() {
205+
const searchContainer = document.querySelector('.task-search')
206+
const searchInput = document.getElementById('task-search-input')
207+
const searchResults = document.getElementById('task-search-results')
208+
if (!searchInput || !searchResults || !searchContainer) return
209+
210+
const tasksBasePath = searchContainer.dataset.tasksPath
211+
let tasks = []
212+
let selectedIndex = -1
213+
214+
const loadTasks = () => {
215+
const taskLinks = document.querySelectorAll('a[href*="/tasks/"]')
216+
const taskSet = new Set()
217+
taskLinks.forEach(link => {
218+
const match = link.href.match(/\/tasks\/([^?/]+)/)
219+
if (match) {
220+
const taskName = decodeURIComponent(match[1])
221+
if (taskName && !taskName.includes('runs')) {
222+
taskSet.add(taskName)
223+
}
224+
}
225+
})
226+
tasks = Array.from(taskSet).sort()
227+
}
228+
229+
const fuzzyMatch = (text, query) => {
230+
const lowerText = text.toLowerCase()
231+
const lowerQuery = query.toLowerCase()
232+
let score = 0
233+
let textIndex = 0
234+
let matchIndices = []
235+
236+
for (let i = 0; i < lowerQuery.length; i++) {
237+
const char = lowerQuery[i]
238+
const foundIndex = lowerText.indexOf(char, textIndex)
239+
if (foundIndex === -1) return null
240+
241+
matchIndices.push(foundIndex)
242+
score += foundIndex === textIndex ? 10 : 1
243+
if (foundIndex === 0 || text[foundIndex - 1] === ':') score += 5
244+
textIndex = foundIndex + 1
245+
}
246+
247+
return { score, matchIndices }
248+
}
249+
250+
const escapeHtml = (text) => {
251+
const div = document.createElement('div')
252+
div.textContent = text
253+
return div.innerHTML
254+
}
255+
256+
const highlightMatch = (text, matchIndices) => {
257+
let result = ''
258+
let lastIndex = 0
259+
matchIndices.forEach(idx => {
260+
result += escapeHtml(text.slice(lastIndex, idx))
261+
result += '<mark>' + escapeHtml(text[idx]) + '</mark>'
262+
lastIndex = idx + 1
263+
})
264+
result += escapeHtml(text.slice(lastIndex))
265+
return result
266+
}
267+
268+
const renderResults = (query) => {
269+
if (!query.trim()) {
270+
searchResults.classList.remove('is-active')
271+
return
272+
}
273+
274+
const matches = tasks
275+
.map(task => ({ task, match: fuzzyMatch(task, query) }))
276+
.filter(item => item.match)
277+
.sort((a, b) => b.match.score - a.match.score)
278+
.slice(0, 10)
279+
280+
if (matches.length === 0) {
281+
searchResults.innerHTML = '<div class="no-results">No tasks found</div>'
282+
} else {
283+
searchResults.innerHTML = matches.map((item, index) =>
284+
'<a href="' + tasksBasePath + '/' + encodeURIComponent(item.task) + '" class="' + (index === selectedIndex ? 'is-selected' : '') + '">' + highlightMatch(item.task, item.match.matchIndices) + '</a>'
285+
).join('')
286+
}
287+
searchResults.classList.add('is-active')
288+
selectedIndex = -1
289+
}
290+
291+
const updateSelection = (items) => {
292+
items.forEach((item, index) => {
293+
item.classList.toggle('is-selected', index === selectedIndex)
294+
})
295+
if (selectedIndex >= 0) {
296+
items[selectedIndex].scrollIntoView({ block: 'nearest' })
297+
}
298+
}
299+
300+
const handleKeydown = (e) => {
301+
const items = searchResults.querySelectorAll('a')
302+
if (!items.length) return
303+
304+
if (e.key === 'ArrowDown') {
305+
e.preventDefault()
306+
selectedIndex = Math.min(selectedIndex + 1, items.length - 1)
307+
updateSelection(items)
308+
} else if (e.key === 'ArrowUp') {
309+
e.preventDefault()
310+
selectedIndex = Math.max(selectedIndex - 1, -1)
311+
updateSelection(items)
312+
} else if (e.key === 'Enter' && selectedIndex >= 0) {
313+
e.preventDefault()
314+
items[selectedIndex].click()
315+
} else if (e.key === 'Escape') {
316+
searchResults.classList.remove('is-active')
317+
searchInput.blur()
318+
}
319+
}
320+
321+
searchInput.addEventListener('input', e => {
322+
loadTasks()
323+
renderResults(e.target.value)
324+
})
325+
searchInput.addEventListener('keydown', handleKeydown)
326+
searchInput.addEventListener('focus', () => {
327+
loadTasks()
328+
if (searchInput.value.trim()) renderResults(searchInput.value)
329+
})
330+
document.addEventListener('click', e => {
331+
if (!e.target.closest('.task-search')) {
332+
searchResults.classList.remove('is-active')
333+
}
334+
})
335+
}
336+
document.addEventListener('DOMContentLoaded', initTaskSearch)
150337
</script>
151338
</head>
152339

test/system/maintenance_tasks/tasks_test.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,31 @@ class TasksTest < ApplicationSystemTestCase
266266
end
267267
end
268268

269+
test "search filters tasks by name" do
270+
visit maintenance_tasks_path
271+
272+
# Search input should be visible
273+
assert_selector "#task-search-input"
274+
275+
# Type in search box
276+
fill_in "task-search-input", with: "Update"
277+
278+
# Should show matching results in dropdown
279+
within ".task-search-results" do
280+
assert_link "Maintenance::UpdatePostsTask"
281+
assert_link "Maintenance::UpdatePostsInBatchesTask"
282+
assert_no_link "Maintenance::ErrorTask"
283+
end
284+
285+
# Click on a result to navigate
286+
within ".task-search-results" do
287+
click_on "Maintenance::UpdatePostsTask"
288+
end
289+
290+
# Should be on the task page
291+
assert_title "Maintenance::UpdatePostsTask"
292+
end
293+
269294
test "sidebar displays namespaces and filters tasks" do
270295
visit maintenance_tasks_path
271296

0 commit comments

Comments
 (0)