Skip to content

Commit 64523dc

Browse files
committed
Allow to save searches from the search interface
Also display if we are using a saved search
1 parent dc595c4 commit 64523dc

9 files changed

Lines changed: 305 additions & 13 deletions

File tree

app/assets/stylesheets/components/topics.css

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,3 +931,98 @@ a.topic-icon {
931931
.search-warnings-list li + li {
932932
margin-top: var(--spacing-1);
933933
}
934+
935+
/* Saved search indicator and save prompt */
936+
.saved-search-indicator {
937+
display: inline-flex;
938+
align-items: center;
939+
gap: var(--spacing-2);
940+
padding: var(--spacing-2) var(--spacing-4);
941+
margin: var(--spacing-3) var(--spacing-4) var(--spacing-4);
942+
background: var(--color-primary-50);
943+
border: var(--border-width) solid var(--color-primary-200);
944+
border-radius: 999px;
945+
color: var(--color-primary-700);
946+
font-size: var(--font-size-sm);
947+
font-weight: var(--font-weight-medium);
948+
}
949+
950+
.save-search-prompt {
951+
padding: var(--spacing-3) var(--spacing-4);
952+
margin-bottom: var(--spacing-4);
953+
}
954+
955+
.save-search-toggle {
956+
display: inline-flex;
957+
align-items: center;
958+
gap: var(--spacing-2);
959+
color: var(--color-text-secondary);
960+
font-size: var(--font-size-sm);
961+
cursor: pointer;
962+
transition: color var(--transition-fast);
963+
}
964+
965+
.save-search-toggle:hover {
966+
color: var(--color-text-link);
967+
}
968+
969+
.save-search-form {
970+
margin-top: var(--spacing-3);
971+
}
972+
973+
.save-search-form.is-hidden {
974+
display: none;
975+
}
976+
977+
.save-search-fields {
978+
display: flex;
979+
gap: var(--spacing-3);
980+
align-items: center;
981+
}
982+
983+
.save-search-input {
984+
display: inline-block;
985+
width: auto;
986+
margin: 0;
987+
padding: var(--spacing-3) var(--spacing-4);
988+
border: var(--border-width) solid var(--color-border);
989+
border-radius: 999px;
990+
font-size: var(--font-size-sm);
991+
background: var(--color-bg-input);
992+
flex: 1;
993+
min-width: 0;
994+
}
995+
996+
.save-search-input:focus {
997+
outline: none;
998+
border-color: var(--color-border-focus);
999+
box-shadow: var(--shadow-focus);
1000+
}
1001+
1002+
.save-search-button {
1003+
display: inline-flex;
1004+
width: auto;
1005+
margin: 0;
1006+
padding: var(--spacing-3) var(--spacing-6);
1007+
border: var(--border-width) solid var(--color-border);
1008+
border-radius: 999px;
1009+
background: var(--color-bg-button);
1010+
color: var(--color-text-button);
1011+
font-size: var(--font-size-sm);
1012+
font-weight: var(--font-weight-semibold);
1013+
cursor: pointer;
1014+
flex-shrink: 0;
1015+
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
1016+
}
1017+
1018+
.save-search-button:hover {
1019+
background: #d9480f;
1020+
box-shadow: var(--shadow-sm);
1021+
transform: translateY(-1px);
1022+
}
1023+
1024+
.save-search-error {
1025+
color: var(--color-danger);
1026+
font-size: var(--font-size-sm);
1027+
margin-top: var(--spacing-2);
1028+
}

app/controllers/settings/saved_searches_controller.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,15 @@ def create
2222
@saved_search = current_user.saved_searches.build(saved_search_params)
2323
@saved_search.scope = "user"
2424
if @saved_search.save
25-
redirect_to settings_saved_searches_path, notice: "Saved search created"
25+
respond_to do |format|
26+
format.html { redirect_to settings_saved_searches_path, notice: "Saved search created" }
27+
format.json { render json: { redirect_url: search_topics_path(saved_search_id: @saved_search.id) } }
28+
end
2629
else
27-
render :new, status: :unprocessable_entity
30+
respond_to do |format|
31+
format.html { render :new, status: :unprocessable_entity }
32+
format.json { render json: { errors: @saved_search.errors.full_messages }, status: :unprocessable_entity }
33+
end
2834
end
2935
end
3036

app/controllers/topics_controller.rb

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,24 @@ def index
2828

2929
def new_topics_count
3030
@viewing_since = viewing_since_param
31-
base_query = topics_base_query(search_query: params[:q])
31+
32+
search_query = if params[:saved_search_id].present?
33+
base_scope = SavedSearch.visible_to(user_signed_in? ? current_user : nil)
34+
saved_search = base_scope.find(params[:saved_search_id])
35+
if params[:team_id].present? && saved_search.scope_team?
36+
saved_search.resolve_query(team: Team.find(params[:team_id]))
37+
else
38+
saved_search.resolve_query
39+
end
40+
else
41+
params[:q]
42+
end
43+
44+
base_query = topics_base_query(search_query: search_query)
3245
@new_topics_count = count_new_topics(base_query, @viewing_since)
33-
refresh_path = params[:q].present? ? search_topics_path(q: params[:q]) : topics_path
46+
47+
refresh_params = params[:saved_search_id].present? ? { saved_search_id: params[:saved_search_id], team_id: params[:team_id] }.compact : { q: params[:q] }
48+
refresh_path = search_query.present? ? search_topics_path(**refresh_params) : topics_path
3449

3550
render partial: "new_topics_banner", locals: { count: @new_topics_count, viewing_since: @viewing_since, refresh_path: refresh_path }
3651
end
@@ -202,7 +217,19 @@ def latest_patchset
202217
end
203218

204219
def search
205-
@search_query = params[:q].to_s.strip
220+
if params[:saved_search_id].present?
221+
base_scope = SavedSearch.visible_to(user_signed_in? ? current_user : nil)
222+
@saved_search = base_scope.find(params[:saved_search_id])
223+
if params[:team_id].present? && @saved_search.scope_team?
224+
@saved_search_team = Team.find(params[:team_id])
225+
@search_query = @saved_search.resolve_query(team: @saved_search_team)
226+
else
227+
@search_query = @saved_search.resolve_query
228+
end
229+
else
230+
@search_query = params[:q].to_s.strip
231+
end
232+
206233
@viewing_since = viewing_since_param
207234
@new_topics_count = 0
208235

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["form", "nameInput"]
5+
static values = { url: String }
6+
7+
toggle() {
8+
this.formTarget.classList.toggle("is-hidden")
9+
if (!this.formTarget.classList.contains("is-hidden")) {
10+
this.nameInputTarget.focus()
11+
}
12+
}
13+
14+
async submit(event) {
15+
event.preventDefault()
16+
const form = event.target.closest("form") || event.target
17+
const formData = new FormData(form)
18+
19+
const response = await fetch(this.urlValue, {
20+
method: "POST",
21+
headers: {
22+
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
23+
"Accept": "application/json"
24+
},
25+
body: formData
26+
})
27+
28+
if (response.ok) {
29+
const data = await response.json()
30+
window.location.href = data.redirect_url
31+
} else {
32+
const data = await response.json().catch(() => null)
33+
const message = data?.errors?.join(", ") || "Failed to save search"
34+
this.formTarget.insertAdjacentHTML("beforeend", `<p class="save-search-error">${message}</p>`)
35+
}
36+
}
37+
}

app/views/topics/_sidebar.html.slim

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@
2727
.sidebar-section
2828
h3.sidebar-heading Saved searches
2929
.sidebar-content
30-
- active_search = params[:q]
30+
- active_ss_id = params[:saved_search_id]&.to_i
31+
- active_team_id = params[:team_id]&.to_i
3132

3233
- global_searches = @saved_searches.select(&:scope_global?)
3334
- if global_searches.any?
3435
.filter-subsection
3536
span.filter-subsection-label Global
3637
ul.quick-filters
3738
- global_searches.each do |ss|
38-
li = link_to ss.name, search_topics_path(q: ss.query), class: ("quick-filter-link is-active" if active_search == ss.query)
39+
li = link_to ss.name, search_topics_path(saved_search_id: ss.id), class: ("quick-filter-link is-active" if active_ss_id == ss.id)
3940

4041
- if user_signed_in?
4142
- user_searches = @saved_searches.select { |ss| ss.scope_user? && ss.user_id.present? }
@@ -46,7 +47,7 @@
4647
span.filter-subsection-label My searches
4748
ul.quick-filters
4849
- all_user.each do |ss|
49-
li = link_to ss.name, search_topics_path(q: ss.query), class: ("quick-filter-link is-active" if active_search == ss.query)
50+
li = link_to ss.name, search_topics_path(saved_search_id: ss.id), class: ("quick-filter-link is-active" if active_ss_id == ss.id)
5051

5152
- team_templates = @saved_searches.select { |ss| ss.scope_team? && ss.team_id.nil? }
5253
- team_specific = @saved_searches.select { |ss| ss.scope_team? && ss.team_id.present? }
@@ -58,7 +59,7 @@
5859
span.filter-subsection-label = "#{team.name} searches"
5960
ul.quick-filters
6061
- all_team.each do |ss|
61-
li = link_to ss.name, search_topics_path(q: ss.resolve_query(team: team)), class: ("quick-filter-link is-active" if active_search == ss.resolve_query(team: team))
62+
li = link_to ss.name, search_topics_path(saved_search_id: ss.id, team_id: team.id), class: ("quick-filter-link is-active" if active_ss_id == ss.id && (!ss.scope_team? || active_team_id == team.id))
6263

6364
- if user_signed_in? && @available_note_tags.present?
6465
.sidebar-section

app/views/topics/search.html.slim

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,28 @@
22

33
= render partial: "sidebar", locals: { available_note_tags: @available_note_tags, search_query: @search_query }
44

5-
#new-topics-banner data-controller="new-topics-banner" data-new-topics-banner-url-value=new_topics_count_topics_path(q: @search_query, viewing_since: @viewing_since.iso8601) data-new-topics-banner-interval-ms-value="180000"
6-
= render partial: "new_topics_banner", locals: { count: @new_topics_count, viewing_since: @viewing_since, refresh_path: search_topics_path(q: @search_query) }
5+
- search_params = @saved_search ? { saved_search_id: @saved_search.id, team_id: @saved_search_team&.id }.compact : { q: @search_query }
6+
7+
- if @saved_search
8+
.saved-search-indicator
9+
i.fas.fa-bookmark
10+
span
11+
| Saved search:
12+
strong<> #{@saved_search.name}
13+
- elsif @search_query.present? && user_signed_in?
14+
.save-search-prompt data-controller="save-search" data-save-search-url-value=settings_saved_searches_path
15+
.save-search-toggle data-action="click->save-search#toggle"
16+
i.far.fa-bookmark
17+
span Save this search
18+
.save-search-form.is-hidden data-save-search-target="form"
19+
= form_with url: settings_saved_searches_path, method: :post, data: { action: "submit->save-search#submit" } do |f|
20+
.save-search-fields
21+
= f.text_field "saved_search[name]", placeholder: "Name for this search...", class: "save-search-input", required: true, data: { "save-search-target": "nameInput" }
22+
= f.hidden_field "saved_search[query]", value: @search_query
23+
= f.submit "Save", class: "save-search-button"
24+
25+
#new-topics-banner data-controller="new-topics-banner" data-new-topics-banner-url-value=new_topics_count_topics_path(**search_params, viewing_since: @viewing_since.iso8601) data-new-topics-banner-interval-ms-value="180000"
26+
= render partial: "new_topics_banner", locals: { count: @new_topics_count, viewing_since: @viewing_since, refresh_path: search_topics_path(**search_params) }
727

828
- if @search_error.present?
929
.search-error
@@ -44,7 +64,7 @@
4464
- if @topics.size == 25
4565
- last_topic = @topics.last
4666
- cursor = "#{last_topic.last_activity.iso8601}_#{last_topic.id}"
47-
= turbo_frame_tag "pagination", src: search_topics_path(q: @search_query, cursor: cursor, viewing_since: @viewing_since.iso8601, longpage: @current_longpage, format: :turbo_stream), loading: :lazy do
67+
= turbo_frame_tag "pagination", src: search_topics_path(**search_params, cursor: cursor, viewing_since: @viewing_since.iso8601, longpage: @current_longpage, format: :turbo_stream), loading: :lazy do
4868
.loading-indicator Loading more results...
4969
- else
5070
= turbo_frame_tag "pagination"

app/views/topics/search.turbo_stream.slim

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
= turbo_stream.append "user-state-requests" do
77
= turbo_frame_tag frame_id, src: user_state_frame_topics_path(topic_ids: @topics.map(&:id), format: :turbo_stream), loading: :eager
88

9+
- search_params = @saved_search ? { saved_search_id: @saved_search.id, team_id: @saved_search_team&.id }.compact : { q: @search_query }
10+
911
- if @topics.size == 25
1012
- last_topic = @topics.last
1113
- cursor = "#{last_topic.last_activity.iso8601}_#{last_topic.id}"
1214
= turbo_stream.replace "pagination" do
13-
= turbo_frame_tag "pagination", src: search_topics_path(q: @search_query, cursor: cursor, viewing_since: @viewing_since.iso8601, longpage: @current_longpage, format: :turbo_stream), loading: :lazy do
15+
= turbo_frame_tag "pagination", src: search_topics_path(**search_params, cursor: cursor, viewing_since: @viewing_since.iso8601, longpage: @current_longpage, format: :turbo_stream), loading: :lazy do
1416
.loading-indicator Loading more results...
1517
- elsif params[:cursor].present?
1618
= turbo_stream.remove "pagination"

spec/requests/settings/saved_searches_spec.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,21 @@ def sign_in(user)
8585
search = SavedSearch.last
8686
expect(search.scope).to eq("user")
8787
end
88+
89+
context "with JSON format" do
90+
it "returns JSON with redirect_url on success" do
91+
user = create(:user)
92+
sign_in(user)
93+
94+
post settings_saved_searches_path,
95+
params: { saved_search: { name: "My New Search", query: "from:me" } },
96+
headers: { "Accept" => "application/json" }
97+
expect(response).to have_http_status(:success)
98+
json = JSON.parse(response.body)
99+
saved = SavedSearch.last
100+
expect(json["redirect_url"]).to include("saved_search_id=#{saved.id}")
101+
end
102+
end
88103
end
89104

90105
describe "PATCH /settings/saved_searches/:id" do

0 commit comments

Comments
 (0)