autocomplete: render html server-side.
Render the HTML for autocomplete results server-side instead of in Javascript. This is cleaner than building HTML in Javascript, but it may hurt caching because the HTTP responses are larger. Fixes #4698: user autocomplete contains links to /posts Also fixes a bug where tag counts in the autocomplete menu were different from tag counts displayed elsewhere because of differences in rounding.
This commit is contained in:
23
app/components/autocomplete_component.rb
Normal file
23
app/components/autocomplete_component.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AutocompleteComponent < ApplicationComponent
|
||||||
|
attr_reader :autocomplete_service
|
||||||
|
|
||||||
|
delegate :humanized_number, to: :helpers
|
||||||
|
delegate :autocomplete_results, to: :autocomplete_service
|
||||||
|
|
||||||
|
def initialize(autocomplete_service:)
|
||||||
|
@autocomplete_service = autocomplete_service
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_to_result(result, &block)
|
||||||
|
case result.type
|
||||||
|
when "user"
|
||||||
|
link_to user_path(result.id), class: "user-#{result.level}", "@click.prevent": "", &block
|
||||||
|
when "pool"
|
||||||
|
link_to pool_path(result.id), class: "pool-category-#{result.category}", "@click.prevent": "", &block
|
||||||
|
else
|
||||||
|
link_to posts_path(tags: result.value), class: "tag-type-#{result.category}", "@click.prevent": "", &block
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<ul>
|
||||||
|
<% autocomplete_results.each do |result| %>
|
||||||
|
<%= tag.li class: "ui-menu-item", "data-autocomplete-type": result.type, "data-autocomplete-antecedent": result.antecedent, "data-autocomplete-value": result.value, "data-autocomplete-category": result.category, "data-autocomplete-post-count": result.post_count do %>
|
||||||
|
<div class="ui-menu-item-wrapper" tabindex="-1">
|
||||||
|
<%= link_to_result result do %>
|
||||||
|
<% if result.antecedent.present? %>
|
||||||
|
<span class="autocomplete-antecedent"><%= result.antecedent.tr("_", " ") %></span>
|
||||||
|
<span class="autocomplete-arrow">→</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= result.label %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if result.post_count %>
|
||||||
|
<%= tag.span humanized_number(result.post_count), class: "post-count", style: "float: right;" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AutocompleteController < ApplicationController
|
class AutocompleteController < ApplicationController
|
||||||
respond_to :xml, :json
|
respond_to :html, :xml, :json
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@query = params.dig(:search, :query)
|
@query = params.dig(:search, :query)
|
||||||
@@ -14,6 +14,6 @@ class AutocompleteController < ApplicationController
|
|||||||
@public = @autocomplete.cache_publicly?
|
@public = @autocomplete.cache_publicly?
|
||||||
|
|
||||||
expires_in @expires_in, public: @public unless response.cache_control.present?
|
expires_in @expires_in, public: @public unless response.cache_control.present?
|
||||||
respond_with(@results)
|
respond_with(@results, layout: false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
let Autocomplete = {};
|
let Autocomplete = {};
|
||||||
|
|
||||||
|
Autocomplete.VERSION = 1; // This should be bumped whenever the /autocomplete API changes in order to invalid client caches.
|
||||||
Autocomplete.MAX_RESULTS = 10;
|
Autocomplete.MAX_RESULTS = 10;
|
||||||
|
|
||||||
Autocomplete.initialize_all = function() {
|
Autocomplete.initialize_all = function() {
|
||||||
@@ -138,64 +139,28 @@ Autocomplete.on_tab = function(event) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Autocomplete.render_item = function(list, item) {
|
Autocomplete.render_item = function(list, item) {
|
||||||
var $link = $("<a/>");
|
item.html.data("ui-autocomplete-item", item);
|
||||||
$link.text(item.label);
|
return list.append(item.html);
|
||||||
$link.attr("href", "/posts?tags=" + encodeURIComponent(item.value));
|
|
||||||
$link.on("click.danbooru", function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (item.antecedent) {
|
|
||||||
var antecedent = item.antecedent.replace(/_/g, " ");
|
|
||||||
var arrow = $("<span/>").html(" → ").addClass("autocomplete-arrow");
|
|
||||||
var antecedent_element = $("<span/>").text(antecedent).addClass("autocomplete-antecedent");
|
|
||||||
$link.prepend([
|
|
||||||
antecedent_element,
|
|
||||||
arrow
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.post_count !== undefined) {
|
|
||||||
var count = item.post_count;
|
|
||||||
|
|
||||||
if (count >= 1000) {
|
|
||||||
count = Math.floor(count / 1000) + "k";
|
|
||||||
}
|
|
||||||
|
|
||||||
var $post_count = $("<span/>").addClass("post-count").css("float", "right").text(count);
|
|
||||||
$link.append($post_count);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^tag/.test(item.type)) {
|
|
||||||
$link.addClass("tag-type-" + item.category);
|
|
||||||
} else if (item.type === "user") {
|
|
||||||
var level_class = "user-" + item.level.toLowerCase();
|
|
||||||
$link.addClass(level_class);
|
|
||||||
} else if (item.type === "pool") {
|
|
||||||
$link.addClass("pool-category-" + item.category);
|
|
||||||
}
|
|
||||||
|
|
||||||
var $menu_item = $("<div/>").append($link);
|
|
||||||
var $list_item = $("<li/>").data("item.autocomplete", item).append($menu_item);
|
|
||||||
|
|
||||||
var data_attributes = ["type", "antecedent", "value", "category", "post_count"];
|
|
||||||
data_attributes.forEach(attr => {
|
|
||||||
$list_item.attr(`data-autocomplete-${attr.replace(/_/g, "-")}`, item[attr]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $list_item.appendTo(list);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Autocomplete.autocomplete_source = function(query, type) {
|
Autocomplete.autocomplete_source = async function(query, type) {
|
||||||
if (query === "") {
|
if (query === "") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $.getJSON("/autocomplete.json", {
|
let html = await $.get("/autocomplete", {
|
||||||
"search[query]": query,
|
"search[query]": query,
|
||||||
"search[type]": type,
|
"search[type]": type,
|
||||||
|
"version": Autocomplete.VERSION,
|
||||||
"limit": Autocomplete.MAX_RESULTS
|
"limit": Autocomplete.MAX_RESULTS
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let items = $(html).find("li").toArray().map(item => {
|
||||||
|
let $item = $(item);
|
||||||
|
return { value: $item.attr("data-autocomplete-value"), html: $item };
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
Autocomplete.tag_prefixes = function() {
|
Autocomplete.tag_prefixes = function() {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class AutocompleteService
|
|||||||
# @return [Array<Hash>] the autocomplete results
|
# @return [Array<Hash>] the autocomplete results
|
||||||
def autocomplete_results
|
def autocomplete_results
|
||||||
return [] if !enabled?
|
return [] if !enabled?
|
||||||
|
return autocomplete_opensearch(query) if type == :opensearch
|
||||||
|
|
||||||
case type
|
case type
|
||||||
when :tag_query
|
when :tag_query
|
||||||
@@ -68,11 +69,9 @@ class AutocompleteService
|
|||||||
autocomplete_favorite_group(query)
|
autocomplete_favorite_group(query)
|
||||||
when :saved_search_label
|
when :saved_search_label
|
||||||
autocomplete_saved_search_label(query)
|
autocomplete_saved_search_label(query)
|
||||||
when :opensearch
|
|
||||||
autocomplete_opensearch(query)
|
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end.map { |result| OpenStruct.new(result) }
|
||||||
end
|
end
|
||||||
|
|
||||||
# Complete a tag search (a regular tag or a metatag)
|
# Complete a tag search (a regular tag or a metatag)
|
||||||
@@ -238,7 +237,7 @@ class AutocompleteService
|
|||||||
pools = Pool.undeleted.name_matches(string).search(order: "post_count").limit(limit)
|
pools = Pool.undeleted.name_matches(string).search(order: "post_count").limit(limit)
|
||||||
|
|
||||||
pools.map do |pool|
|
pools.map do |pool|
|
||||||
{ type: "pool", label: pool.pretty_name, value: pool.name, post_count: pool.post_count, category: pool.category }
|
{ type: "pool", label: pool.pretty_name, value: pool.name, id: pool.id, post_count: pool.post_count, category: pool.category }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -298,7 +297,7 @@ class AutocompleteService
|
|||||||
users = User.search(name_matches: string, current_user_first: true, order: "post_upload_count").limit(limit)
|
users = User.search(name_matches: string, current_user_first: true, order: "post_upload_count").limit(limit)
|
||||||
|
|
||||||
users.map do |user|
|
users.map do |user|
|
||||||
{ type: "user", label: user.pretty_name, value: user.name, level: user.level_string }
|
{ type: "user", label: user.pretty_name, value: user.name, id: user.id, level: user.level_string.downcase }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
1
app/views/autocomplete/index.html.erb
Normal file
1
app/views/autocomplete/index.html.erb
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<%= render AutocompleteComponent.new(autocomplete_service: @autocomplete) %>
|
||||||
@@ -2,10 +2,10 @@ require "test_helper"
|
|||||||
|
|
||||||
class AutocompleteControllerTest < ActionDispatch::IntegrationTest
|
class AutocompleteControllerTest < ActionDispatch::IntegrationTest
|
||||||
def autocomplete(query, type)
|
def autocomplete(query, type)
|
||||||
get autocomplete_index_path(search: { query: query, type: type }), as: :json
|
get autocomplete_index_path(search: { query: query, type: type })
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
||||||
response.parsed_body.map { |result| result["value"] }
|
response.parsed_body.css("li").map { |html| html["data-autocomplete-value"] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def assert_autocomplete_equals(expected_value, query, type)
|
def assert_autocomplete_equals(expected_value, query, type)
|
||||||
@@ -35,6 +35,24 @@ class AutocompleteControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_autocomplete_equals(["rating:sensitive"], "-rating:s", "tag_query")
|
assert_autocomplete_equals(["rating:sensitive"], "-rating:s", "tag_query")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
should "work for an aliased tag" do
|
||||||
|
create(:tag_alias, antecedent_name: "oc", consequent_name: "original")
|
||||||
|
|
||||||
|
assert_autocomplete_equals(["original"], "oc", "tag_query")
|
||||||
|
end
|
||||||
|
|
||||||
|
should "work for the user: metatag" do
|
||||||
|
create(:user, name: "foobar")
|
||||||
|
|
||||||
|
assert_autocomplete_equals(["user:foobar"], "user:foo", "tag_query")
|
||||||
|
end
|
||||||
|
|
||||||
|
should "work for the pool: metatag" do
|
||||||
|
as(create(:user)) { create(:pool, name: "foobar") }
|
||||||
|
|
||||||
|
assert_autocomplete_equals(["pool:foobar"], "pool:foo", "tag_query")
|
||||||
|
end
|
||||||
|
|
||||||
should "work for a missing type" do
|
should "work for a missing type" do
|
||||||
get autocomplete_index_path(search: { query: "azur" }), as: :json
|
get autocomplete_index_path(search: { query: "azur" }), as: :json
|
||||||
|
|
||||||
@@ -48,7 +66,7 @@ class AutocompleteControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
should "not set session cookies when the response is publicly cached" do
|
should "not set session cookies when the response is publicly cached" do
|
||||||
get autocomplete_index_path(search: { query: "azur", type: "tag_query" }), as: :json
|
get autocomplete_index_path(search: { query: "azur", type: "tag_query" })
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_equal(true, response.cache_control[:public])
|
assert_equal(true, response.cache_control[:public])
|
||||||
|
|||||||
Reference in New Issue
Block a user