claude-agent-framework 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/bin/claude-framework +3 -0
- package/framework/agents/design-lead.md +240 -0
- package/framework/agents/product-owner.md +179 -0
- package/framework/agents/tech-lead.md +226 -0
- package/framework/commands/ayuda.md +127 -0
- package/framework/commands/a/303/261adir.md +98 -0
- package/framework/commands/backup.md +397 -0
- package/framework/commands/cambiar.md +110 -0
- package/framework/commands/cloud.md +457 -0
- package/framework/commands/code.md +142 -0
- package/framework/commands/debug.md +334 -0
- package/framework/commands/deploy.md +383 -0
- package/framework/commands/deshacer.md +120 -0
- package/framework/commands/estado.md +218 -0
- package/framework/commands/explica.md +227 -0
- package/framework/commands/feature.md +120 -0
- package/framework/commands/git.md +427 -0
- package/framework/commands/historial.md +202 -0
- package/framework/commands/learn.md +408 -0
- package/framework/commands/movil.md +245 -0
- package/framework/commands/nuevo.md +118 -0
- package/framework/commands/plan.md +134 -0
- package/framework/commands/prd.md +113 -0
- package/framework/commands/probar.md +148 -0
- package/framework/commands/revisar.md +208 -0
- package/framework/commands/seeds.md +230 -0
- package/framework/commands/seguridad.md +226 -0
- package/framework/commands/tasks.md +157 -0
- package/framework/skills/architecture/algorithms.md +970 -0
- package/framework/skills/architecture/clean-code.md +1080 -0
- package/framework/skills/architecture/design-patterns.md +1984 -0
- package/framework/skills/architecture/functional-programming.md +972 -0
- package/framework/skills/architecture/solid.md +991 -0
- package/framework/skills/cloud/cloud-aws.md +848 -0
- package/framework/skills/cloud/cloud-azure.md +931 -0
- package/framework/skills/cloud/cloud-gcp.md +848 -0
- package/framework/skills/cloud/message-queues.md +1229 -0
- package/framework/skills/core/accessibility.md +401 -0
- package/framework/skills/core/api.md +474 -0
- package/framework/skills/core/authentication.md +306 -0
- package/framework/skills/core/authorization.md +388 -0
- package/framework/skills/core/background-jobs.md +341 -0
- package/framework/skills/core/caching.md +473 -0
- package/framework/skills/core/code-review.md +341 -0
- package/framework/skills/core/controllers.md +290 -0
- package/framework/skills/core/cua.md +285 -0
- package/framework/skills/core/documentation.md +472 -0
- package/framework/skills/core/file-uploads.md +351 -0
- package/framework/skills/core/hotwire-native.md +296 -0
- package/framework/skills/core/hotwire.md +278 -0
- package/framework/skills/core/i18n.md +334 -0
- package/framework/skills/core/imports-exports.md +750 -0
- package/framework/skills/core/infrastructure.md +337 -0
- package/framework/skills/core/models.md +228 -0
- package/framework/skills/core/notifications.md +672 -0
- package/framework/skills/core/payments.md +581 -0
- package/framework/skills/core/performance.md +361 -0
- package/framework/skills/core/rails-scaffold.md +131 -0
- package/framework/skills/core/search.md +518 -0
- package/framework/skills/core/security.md +565 -0
- package/framework/skills/core/seeds.md +307 -0
- package/framework/skills/core/seo.md +542 -0
- package/framework/skills/core/testing.md +393 -0
- package/framework/skills/core/views.md +260 -0
- package/framework/skills/core/websockets.md +564 -0
- package/framework/skills/data/advanced-sql.md +1204 -0
- package/framework/skills/data/nosql.md +1141 -0
- package/framework/skills/devops/containers-advanced.md +1237 -0
- package/framework/skills/devops/debugging.md +834 -0
- package/framework/skills/devops/git-workflow.md +752 -0
- package/framework/skills/devops/networking.md +932 -0
- package/framework/skills/devops/shell-scripting.md +1132 -0
- package/framework/sub-agents/architecture-patterns-agent.md +1450 -0
- package/framework/sub-agents/cloud-agent.md +677 -0
- package/framework/sub-agents/data.md +504 -0
- package/framework/sub-agents/debugging-agent.md +554 -0
- package/framework/sub-agents/devops.md +483 -0
- package/framework/sub-agents/docs.md +176 -0
- package/framework/sub-agents/frontend-dev.md +349 -0
- package/framework/sub-agents/git-workflow-agent.md +697 -0
- package/framework/sub-agents/integrations.md +630 -0
- package/framework/sub-agents/native-dev.md +434 -0
- package/framework/sub-agents/qa.md +138 -0
- package/framework/sub-agents/rails-dev.md +375 -0
- package/framework/sub-agents/security.md +526 -0
- package/framework/sub-agents/ui.md +437 -0
- package/framework/sub-agents/ux.md +284 -0
- package/framework/templates/api-spec.md +500 -0
- package/framework/templates/component-spec.md +248 -0
- package/framework/templates/feature.json +13 -0
- package/framework/templates/model-spec.md +318 -0
- package/framework/templates/prd-template.md +80 -0
- package/framework/templates/task-plan.md +122 -0
- package/framework/templates/task-user-story.md +52 -0
- package/framework/templates/technical-spec.md +260 -0
- package/framework/templates/user-story.md +95 -0
- package/package.json +42 -0
- package/project-templates/CLAUDE.md +42 -0
- package/project-templates/contexts/architecture.md +25 -0
- package/project-templates/contexts/conventions.md +46 -0
- package/project-templates/contexts/design-system.md +47 -0
- package/project-templates/contexts/requirements.md +38 -0
- package/project-templates/contexts/stack.md +30 -0
- package/project-templates/history/active/models.md +11 -0
- package/project-templates/history/changelog.md +15 -0
- package/project-templates/workspace/.gitkeep +0 -0
- package/src/cli.js +52 -0
- package/src/init.js +104 -0
- package/src/status.js +75 -0
- package/src/update.js +88 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# Skill: Search
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Implementar funcionalidades de búsqueda en aplicaciones Rails, desde búsqueda simple hasta full-text search.
|
|
6
|
+
|
|
7
|
+
## Búsqueda simple con SQLite
|
|
8
|
+
|
|
9
|
+
### LIKE queries
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# app/models/post.rb
|
|
13
|
+
class Post < ApplicationRecord
|
|
14
|
+
scope :search, ->(query) {
|
|
15
|
+
return all if query.blank?
|
|
16
|
+
|
|
17
|
+
where("title LIKE ? OR body LIKE ?", "%#{query}%", "%#{query}%")
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Uso
|
|
22
|
+
Post.search(params[:q])
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Búsqueda case-insensitive
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# SQLite
|
|
29
|
+
scope :search, ->(query) {
|
|
30
|
+
return all if query.blank?
|
|
31
|
+
|
|
32
|
+
where("LOWER(title) LIKE LOWER(?) OR LOWER(body) LIKE LOWER(?)",
|
|
33
|
+
"%#{query}%", "%#{query}%")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# O con COLLATE NOCASE
|
|
37
|
+
scope :search, ->(query) {
|
|
38
|
+
return all if query.blank?
|
|
39
|
+
|
|
40
|
+
where("title LIKE ? COLLATE NOCASE OR body LIKE ? COLLATE NOCASE",
|
|
41
|
+
"%#{query}%", "%#{query}%")
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Búsqueda en múltiples campos
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class Post < ApplicationRecord
|
|
49
|
+
include Searchable
|
|
50
|
+
|
|
51
|
+
searchable_fields :title, :body, :author_name
|
|
52
|
+
|
|
53
|
+
def author_name
|
|
54
|
+
author&.name
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# app/models/concerns/searchable.rb
|
|
59
|
+
module Searchable
|
|
60
|
+
extend ActiveSupport::Concern
|
|
61
|
+
|
|
62
|
+
class_methods do
|
|
63
|
+
def searchable_fields(*fields)
|
|
64
|
+
@searchable_fields = fields
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def search(query)
|
|
68
|
+
return all if query.blank?
|
|
69
|
+
|
|
70
|
+
fields = @searchable_fields || [:name]
|
|
71
|
+
conditions = fields.map { |f| "LOWER(#{f}) LIKE LOWER(?)" }.join(" OR ")
|
|
72
|
+
values = fields.map { "%#{query}%" }
|
|
73
|
+
|
|
74
|
+
where(conditions, *values)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## SQLite FTS5 (Full-Text Search)
|
|
81
|
+
|
|
82
|
+
### Setup
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# db/migrate/xxx_create_posts_fts.rb
|
|
86
|
+
class CreatePostsFts < ActiveRecord::Migration[8.0]
|
|
87
|
+
def up
|
|
88
|
+
execute <<-SQL
|
|
89
|
+
CREATE VIRTUAL TABLE posts_fts USING fts5(
|
|
90
|
+
title,
|
|
91
|
+
body,
|
|
92
|
+
content='posts',
|
|
93
|
+
content_rowid='id'
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
-- Triggers para mantener sincronizado
|
|
97
|
+
CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
|
|
98
|
+
INSERT INTO posts_fts(rowid, title, body)
|
|
99
|
+
VALUES (new.id, new.title, new.body);
|
|
100
|
+
END;
|
|
101
|
+
|
|
102
|
+
CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN
|
|
103
|
+
INSERT INTO posts_fts(posts_fts, rowid, title, body)
|
|
104
|
+
VALUES ('delete', old.id, old.title, old.body);
|
|
105
|
+
END;
|
|
106
|
+
|
|
107
|
+
CREATE TRIGGER posts_au AFTER UPDATE ON posts BEGIN
|
|
108
|
+
INSERT INTO posts_fts(posts_fts, rowid, title, body)
|
|
109
|
+
VALUES ('delete', old.id, old.title, old.body);
|
|
110
|
+
INSERT INTO posts_fts(rowid, title, body)
|
|
111
|
+
VALUES (new.id, new.title, new.body);
|
|
112
|
+
END;
|
|
113
|
+
SQL
|
|
114
|
+
|
|
115
|
+
# Poblar FTS con datos existentes
|
|
116
|
+
execute <<-SQL
|
|
117
|
+
INSERT INTO posts_fts(rowid, title, body)
|
|
118
|
+
SELECT id, title, body FROM posts;
|
|
119
|
+
SQL
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def down
|
|
123
|
+
execute "DROP TABLE IF EXISTS posts_fts"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Modelo con FTS
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# app/models/post.rb
|
|
132
|
+
class Post < ApplicationRecord
|
|
133
|
+
scope :full_text_search, ->(query) {
|
|
134
|
+
return all if query.blank?
|
|
135
|
+
|
|
136
|
+
# Sanitizar query
|
|
137
|
+
sanitized = query.gsub(/[^\w\s]/, "")
|
|
138
|
+
|
|
139
|
+
joins("INNER JOIN posts_fts ON posts_fts.rowid = posts.id")
|
|
140
|
+
.where("posts_fts MATCH ?", sanitized)
|
|
141
|
+
.select("posts.*, rank AS search_rank")
|
|
142
|
+
.order("search_rank")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Con highlighting
|
|
146
|
+
scope :search_with_highlights, ->(query) {
|
|
147
|
+
return all if query.blank?
|
|
148
|
+
|
|
149
|
+
sanitized = query.gsub(/[^\w\s]/, "")
|
|
150
|
+
|
|
151
|
+
joins("INNER JOIN posts_fts ON posts_fts.rowid = posts.id")
|
|
152
|
+
.where("posts_fts MATCH ?", sanitized)
|
|
153
|
+
.select(
|
|
154
|
+
"posts.*",
|
|
155
|
+
"highlight(posts_fts, 0, '<mark>', '</mark>') AS title_highlight",
|
|
156
|
+
"snippet(posts_fts, 1, '<mark>', '</mark>', '...', 32) AS body_snippet"
|
|
157
|
+
)
|
|
158
|
+
.order("rank")
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Ransack (búsqueda avanzada)
|
|
164
|
+
|
|
165
|
+
### Setup
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
# Gemfile
|
|
169
|
+
gem "ransack"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Controller
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
class PostsController < ApplicationController
|
|
176
|
+
def index
|
|
177
|
+
@q = Post.ransack(params[:q])
|
|
178
|
+
@posts = @q.result(distinct: true)
|
|
179
|
+
.includes(:author)
|
|
180
|
+
.page(params[:page])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Vista con formulario de búsqueda
|
|
186
|
+
|
|
187
|
+
```erb
|
|
188
|
+
<%# app/views/posts/index.html.erb %>
|
|
189
|
+
<%= search_form_for @q, url: posts_path, method: :get do |f| %>
|
|
190
|
+
<div class="flex gap-4">
|
|
191
|
+
<%# Búsqueda de texto %>
|
|
192
|
+
<%= f.search_field :title_or_body_cont,
|
|
193
|
+
placeholder: t(".search_placeholder"),
|
|
194
|
+
class: "input" %>
|
|
195
|
+
|
|
196
|
+
<%# Filtro por estado %>
|
|
197
|
+
<%= f.select :status_eq,
|
|
198
|
+
Post.statuses.keys.map { |s| [s.humanize, s] },
|
|
199
|
+
{ include_blank: t(".all_statuses") },
|
|
200
|
+
class: "select" %>
|
|
201
|
+
|
|
202
|
+
<%# Rango de fechas %>
|
|
203
|
+
<%= f.date_field :created_at_gteq, class: "input" %>
|
|
204
|
+
<%= f.date_field :created_at_lteq, class: "input" %>
|
|
205
|
+
|
|
206
|
+
<%# Ordenamiento %>
|
|
207
|
+
<%= f.select :s,
|
|
208
|
+
[
|
|
209
|
+
[t(".newest"), "created_at desc"],
|
|
210
|
+
[t(".oldest"), "created_at asc"],
|
|
211
|
+
[t(".title_az"), "title asc"]
|
|
212
|
+
],
|
|
213
|
+
{},
|
|
214
|
+
class: "select" %>
|
|
215
|
+
|
|
216
|
+
<%= f.submit t(".search"), class: "btn btn-primary" %>
|
|
217
|
+
</div>
|
|
218
|
+
<% end %>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Predicados de Ransack
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
# Predicados disponibles
|
|
225
|
+
title_eq # título igual a
|
|
226
|
+
title_cont # título contiene
|
|
227
|
+
title_start # título empieza con
|
|
228
|
+
title_end # título termina con
|
|
229
|
+
title_present # título está presente
|
|
230
|
+
title_blank # título está vacío
|
|
231
|
+
created_at_gt # fecha mayor que
|
|
232
|
+
created_at_lt # fecha menor que
|
|
233
|
+
created_at_gteq # fecha mayor o igual
|
|
234
|
+
created_at_lteq # fecha menor o igual
|
|
235
|
+
id_in # id en array
|
|
236
|
+
status_not_eq # status diferente de
|
|
237
|
+
|
|
238
|
+
# Combinaciones
|
|
239
|
+
title_or_body_cont # título O cuerpo contiene
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Custom ransackers
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
# app/models/post.rb
|
|
246
|
+
class Post < ApplicationRecord
|
|
247
|
+
# Búsqueda personalizada
|
|
248
|
+
ransacker :published_year do
|
|
249
|
+
Arel.sql("strftime('%Y', published_at)")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Búsqueda en asociación
|
|
253
|
+
ransacker :author_name do
|
|
254
|
+
Arel.sql("(SELECT name FROM users WHERE users.id = posts.author_id)")
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Uso: params[:q][:published_year_eq] = "2024"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Meilisearch (motor de búsqueda externo)
|
|
262
|
+
|
|
263
|
+
### Setup
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
# Gemfile
|
|
267
|
+
gem "meilisearch-rails"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# config/initializers/meilisearch.rb
|
|
272
|
+
MeiliSearch::Rails.configuration = {
|
|
273
|
+
meilisearch_url: ENV.fetch("MEILISEARCH_URL", "http://localhost:7700"),
|
|
274
|
+
meilisearch_api_key: Rails.application.credentials.dig(:meilisearch, :api_key)
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Modelo
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
# app/models/post.rb
|
|
282
|
+
class Post < ApplicationRecord
|
|
283
|
+
include MeiliSearch::Rails
|
|
284
|
+
|
|
285
|
+
meilisearch do
|
|
286
|
+
# Atributos a indexar
|
|
287
|
+
attribute :title, :body, :status
|
|
288
|
+
attribute :author_name do
|
|
289
|
+
author&.name
|
|
290
|
+
end
|
|
291
|
+
attribute :created_at_timestamp do
|
|
292
|
+
created_at.to_i
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Atributos buscables
|
|
296
|
+
searchable_attributes [:title, :body, :author_name]
|
|
297
|
+
|
|
298
|
+
# Atributos filtrables
|
|
299
|
+
filterable_attributes [:status, :author_id, :created_at_timestamp]
|
|
300
|
+
|
|
301
|
+
# Atributos ordenables
|
|
302
|
+
sortable_attributes [:created_at_timestamp, :title]
|
|
303
|
+
|
|
304
|
+
# Ranking personalizado
|
|
305
|
+
ranking_rules [
|
|
306
|
+
"words",
|
|
307
|
+
"typo",
|
|
308
|
+
"proximity",
|
|
309
|
+
"attribute",
|
|
310
|
+
"sort",
|
|
311
|
+
"exactness"
|
|
312
|
+
]
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Solo indexar posts publicados
|
|
316
|
+
def self.meilisearch_settings
|
|
317
|
+
{
|
|
318
|
+
if: -> (post) { post.published? }
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Controller
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
class SearchController < ApplicationController
|
|
328
|
+
def index
|
|
329
|
+
if params[:q].present?
|
|
330
|
+
@results = Post.search(
|
|
331
|
+
params[:q],
|
|
332
|
+
{
|
|
333
|
+
filters: build_filters,
|
|
334
|
+
sort: [params[:sort] || "created_at_timestamp:desc"],
|
|
335
|
+
limit: 20,
|
|
336
|
+
offset: (params[:page].to_i - 1) * 20,
|
|
337
|
+
attributes_to_highlight: ["title", "body"],
|
|
338
|
+
highlight_pre_tag: "<mark>",
|
|
339
|
+
highlight_post_tag: "</mark>"
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
else
|
|
343
|
+
@results = []
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
private
|
|
348
|
+
|
|
349
|
+
def build_filters
|
|
350
|
+
filters = []
|
|
351
|
+
filters << "status = published"
|
|
352
|
+
filters << "author_id = #{params[:author_id]}" if params[:author_id].present?
|
|
353
|
+
filters.join(" AND ")
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Vista con highlights
|
|
359
|
+
|
|
360
|
+
```erb
|
|
361
|
+
<% @results.each do |result| %>
|
|
362
|
+
<article>
|
|
363
|
+
<h2>
|
|
364
|
+
<%# Usar highlighted si está disponible %>
|
|
365
|
+
<%= raw result._formatted&.dig("title") || result.title %>
|
|
366
|
+
</h2>
|
|
367
|
+
<p>
|
|
368
|
+
<%= raw result._formatted&.dig("body")&.truncate(200) || result.body.truncate(200) %>
|
|
369
|
+
</p>
|
|
370
|
+
</article>
|
|
371
|
+
<% end %>
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## Elasticsearch (alternativa enterprise)
|
|
375
|
+
|
|
376
|
+
### Setup básico
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
# Gemfile
|
|
380
|
+
gem "elasticsearch-model"
|
|
381
|
+
gem "elasticsearch-rails"
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
# app/models/post.rb
|
|
386
|
+
class Post < ApplicationRecord
|
|
387
|
+
include Elasticsearch::Model
|
|
388
|
+
include Elasticsearch::Model::Callbacks
|
|
389
|
+
|
|
390
|
+
settings do
|
|
391
|
+
mappings dynamic: "false" do
|
|
392
|
+
indexes :title, type: "text", analyzer: "spanish"
|
|
393
|
+
indexes :body, type: "text", analyzer: "spanish"
|
|
394
|
+
indexes :status, type: "keyword"
|
|
395
|
+
indexes :created_at, type: "date"
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def as_indexed_json(options = {})
|
|
400
|
+
as_json(only: [:title, :body, :status, :created_at])
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## Autocompletado
|
|
406
|
+
|
|
407
|
+
### Con Stimulus
|
|
408
|
+
|
|
409
|
+
```javascript
|
|
410
|
+
// app/javascript/controllers/autocomplete_controller.js
|
|
411
|
+
import { Controller } from "@hotwired/stimulus"
|
|
412
|
+
import { debounce } from "lodash-es"
|
|
413
|
+
|
|
414
|
+
export default class extends Controller {
|
|
415
|
+
static targets = ["input", "results"]
|
|
416
|
+
static values = { url: String, minLength: { type: Number, default: 2 } }
|
|
417
|
+
|
|
418
|
+
connect() {
|
|
419
|
+
this.search = debounce(this.search.bind(this), 300)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async search() {
|
|
423
|
+
const query = this.inputTarget.value
|
|
424
|
+
|
|
425
|
+
if (query.length < this.minLengthValue) {
|
|
426
|
+
this.hideResults()
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`)
|
|
431
|
+
const html = await response.text()
|
|
432
|
+
|
|
433
|
+
this.resultsTarget.innerHTML = html
|
|
434
|
+
this.showResults()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
select(event) {
|
|
438
|
+
this.inputTarget.value = event.currentTarget.dataset.value
|
|
439
|
+
this.hideResults()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
showResults() {
|
|
443
|
+
this.resultsTarget.classList.remove("hidden")
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
hideResults() {
|
|
447
|
+
this.resultsTarget.classList.add("hidden")
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Cerrar al hacer click fuera
|
|
451
|
+
clickOutside(event) {
|
|
452
|
+
if (!this.element.contains(event.target)) {
|
|
453
|
+
this.hideResults()
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Controller de autocompletado
|
|
460
|
+
|
|
461
|
+
```ruby
|
|
462
|
+
class AutocompleteController < ApplicationController
|
|
463
|
+
def posts
|
|
464
|
+
@posts = Post.search(params[:q]).limit(10)
|
|
465
|
+
render partial: "autocomplete/posts", locals: { posts: @posts }
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Vista
|
|
471
|
+
|
|
472
|
+
```erb
|
|
473
|
+
<%# Formulario con autocompletado %>
|
|
474
|
+
<div data-controller="autocomplete"
|
|
475
|
+
data-autocomplete-url-value="<%= autocomplete_posts_path %>"
|
|
476
|
+
data-action="click@window->autocomplete#clickOutside">
|
|
477
|
+
|
|
478
|
+
<%= text_field_tag :q,
|
|
479
|
+
params[:q],
|
|
480
|
+
placeholder: "Buscar...",
|
|
481
|
+
data: {
|
|
482
|
+
autocomplete_target: "input",
|
|
483
|
+
action: "input->autocomplete#search"
|
|
484
|
+
},
|
|
485
|
+
class: "input w-full" %>
|
|
486
|
+
|
|
487
|
+
<div data-autocomplete-target="results"
|
|
488
|
+
class="hidden absolute bg-white border rounded-lg shadow-lg mt-1 w-full z-50">
|
|
489
|
+
<%# Los resultados se cargan aquí %>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<%# app/views/autocomplete/_posts.html.erb %>
|
|
494
|
+
<ul class="divide-y">
|
|
495
|
+
<% posts.each do |post| %>
|
|
496
|
+
<li>
|
|
497
|
+
<button type="button"
|
|
498
|
+
data-action="autocomplete#select"
|
|
499
|
+
data-value="<%= post.title %>"
|
|
500
|
+
class="w-full text-left px-4 py-2 hover:bg-gray-100">
|
|
501
|
+
<%= post.title %>
|
|
502
|
+
<span class="text-gray-500 text-sm block"><%= truncate(post.body, length: 50) %></span>
|
|
503
|
+
</button>
|
|
504
|
+
</li>
|
|
505
|
+
<% end %>
|
|
506
|
+
</ul>
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
## Checklist
|
|
510
|
+
|
|
511
|
+
- [ ] Índices creados en columnas buscadas
|
|
512
|
+
- [ ] Queries sanitizadas (no SQL injection)
|
|
513
|
+
- [ ] Paginación implementada
|
|
514
|
+
- [ ] Búsqueda case-insensitive
|
|
515
|
+
- [ ] Resultados destacados (highlights)
|
|
516
|
+
- [ ] Debounce en autocompletado
|
|
517
|
+
- [ ] Manejo de queries vacías
|
|
518
|
+
- [ ] Performance testeada con datos reales
|