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:
evazion
2022-08-29 20:50:19 -05:00
parent 55266be2ef
commit cf13ab1540
7 changed files with 84 additions and 58 deletions

View 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

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
class AutocompleteController < ApplicationController
respond_to :xml, :json
respond_to :html, :xml, :json
def index
@query = params.dig(:search, :query)
@@ -14,6 +14,6 @@ class AutocompleteController < ApplicationController
@public = @autocomplete.cache_publicly?
expires_in @expires_in, public: @public unless response.cache_control.present?
respond_with(@results)
respond_with(@results, layout: false)
end
end

View File

@@ -1,5 +1,6 @@
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.initialize_all = function() {
@@ -138,64 +139,28 @@ Autocomplete.on_tab = function(event) {
};
Autocomplete.render_item = function(list, item) {
var $link = $("<a/>");
$link.text(item.label);
$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(" &rarr; ").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);
item.html.data("ui-autocomplete-item", item);
return list.append(item.html);
};
Autocomplete.autocomplete_source = function(query, type) {
Autocomplete.autocomplete_source = async function(query, type) {
if (query === "") {
return [];
}
return $.getJSON("/autocomplete.json", {
let html = await $.get("/autocomplete", {
"search[query]": query,
"search[type]": type,
"version": Autocomplete.VERSION,
"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() {

View File

@@ -48,6 +48,7 @@ class AutocompleteService
# @return [Array<Hash>] the autocomplete results
def autocomplete_results
return [] if !enabled?
return autocomplete_opensearch(query) if type == :opensearch
case type
when :tag_query
@@ -68,11 +69,9 @@ class AutocompleteService
autocomplete_favorite_group(query)
when :saved_search_label
autocomplete_saved_search_label(query)
when :opensearch
autocomplete_opensearch(query)
else
[]
end
end.map { |result| OpenStruct.new(result) }
end
# 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.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
@@ -298,7 +297,7 @@ class AutocompleteService
users = User.search(name_matches: string, current_user_first: true, order: "post_upload_count").limit(limit)
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

View File

@@ -0,0 +1 @@
<%= render AutocompleteComponent.new(autocomplete_service: @autocomplete) %>

View File

@@ -2,10 +2,10 @@ require "test_helper"
class AutocompleteControllerTest < ActionDispatch::IntegrationTest
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
response.parsed_body.map { |result| result["value"] }
response.parsed_body.css("li").map { |html| html["data-autocomplete-value"] }
end
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")
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
get autocomplete_index_path(search: { query: "azur" }), as: :json
@@ -48,7 +66,7 @@ class AutocompleteControllerTest < ActionDispatch::IntegrationTest
end
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_equal(true, response.cache_control[:public])