diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-13 03:48:28 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-13 03:48:28 +0200 |
| commit | 5f56fa867f073d8d1aecbfbba4a340ea414cfb0d (patch) | |
| tree | 5f39c0386b2e5819289e916ccdb67b987c2131fa | |
| parent | 70653b681d10917b77dced08f577446ced7568f1 (diff) | |
Add loadOnFocus to initSearchPicker; asset search shows recent by default
initSearchPicker's input handler has always short-circuited on an empty
term -- correct for every existing picker, none of which want results
appearing before anything's typed. The asset picker does want exactly
that (show the last few uploaded images without requiring a search
first), so this adds an opt-in loadOnFocus option instead of changing
the shared default: it fires once when the input gains focus with no
term yet, reusing the same request/render path a real search uses
rather than a separate code path. The AJAX call itself was pulled out
into a named runSearch function so both triggers could share it without
duplicating the success/render logic.
RelatedAssetsController#search now treats a blank term as "show the
most recently created, unattached images" (limit 5) rather than
returning nothing, matching the new client behavior. A real search
term still returns up to 10 name-matched results, unchanged.
| -rw-r--r-- | app/controllers/related_assets_controller.rb | 18 | ||||
| -rw-r--r-- | public/javascripts/admin_search.js | 133 | ||||
| -rw-r--r-- | test/controllers/related_assets_controller_test.rb | 16 |
3 files changed, 91 insertions, 76 deletions
diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb index 906c02c..ae37d2f 100644 --- a/app/controllers/related_assets_controller.rb +++ b/app/controllers/related_assets_controller.rb | |||
| @@ -3,17 +3,15 @@ class RelatedAssetsController < ApplicationController | |||
| 3 | before_action :find_node | 3 | before_action :find_node |
| 4 | 4 | ||
| 5 | def search | 5 | def search |
| 6 | term = params[:q].to_s.strip | 6 | term = params[:search_term].to_s.strip |
| 7 | if term.blank? | ||
| 8 | render json: [] | ||
| 9 | return | ||
| 10 | end | ||
| 11 | |||
| 12 | attached_ids = @node.editable_page.related_assets.pluck(:asset_id) | 7 | attached_ids = @node.editable_page.related_assets.pluck(:asset_id) |
| 13 | results = Asset.images | 8 | scope = Asset.images.where.not(id: attached_ids) |
| 14 | .where("name ILIKE ?", "%#{term}%") | 9 | |
| 15 | .where.not(id: attached_ids) | 10 | results = if term.present? |
| 16 | .limit(10) | 11 | scope.where("name ILIKE ?", "%#{term}%").limit(10) |
| 12 | else | ||
| 13 | scope.order(created_at: :desc).limit(5) | ||
| 14 | end | ||
| 17 | 15 | ||
| 18 | render json: results.map { |a| | 16 | render json: results.map { |a| |
| 19 | { id: a.id, name: a.name, thumb_url: a.upload.url(:thumb) } | 17 | { id: a.id, name: a.name, thumb_url: a.upload.url(:thumb) } |
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 0c768a3..7604bb7 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js | |||
| @@ -42,18 +42,76 @@ function initSearchPicker(options) { | |||
| 42 | var inputSelector = options.inputSelector; | 42 | var inputSelector = options.inputSelector; |
| 43 | var resultsSelector = options.resultsSelector; | 43 | var resultsSelector = options.resultsSelector; |
| 44 | var url = options.url || ADMIN_MENU_SEARCH_URL; | 44 | var url = options.url || ADMIN_MENU_SEARCH_URL; |
| 45 | var onSelect = options.onSelect; // omit for a real-navigation search like admin_search | 45 | var onSelect = options.onSelect; |
| 46 | var isActive = options.isActive; // optional guard, checked before every search | 46 | var isActive = options.isActive; |
| 47 | var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist | 47 | var resultsHeaderHtml = options.resultsHeaderHtml; |
| 48 | var renderResults = options.renderResults; // optional, replaces the default per-node rendering entirely | 48 | var renderResults = options.renderResults; |
| 49 | var loadOnFocus = options.loadOnFocus; // optional, fires an initial search on focus with no term typed yet | ||
| 49 | var requestId = 0; | 50 | var requestId = 0; |
| 50 | var timeout; | 51 | var timeout; |
| 52 | var results = $(resultsSelector); | ||
| 53 | |||
| 54 | function runSearch(term) { | ||
| 55 | var thisRequest = ++requestId; | ||
| 56 | $.ajax({ | ||
| 57 | type: "GET", | ||
| 58 | url: url, | ||
| 59 | data: "search_term=" + term, | ||
| 60 | dataType: "json", | ||
| 61 | success: function(data) { | ||
| 62 | if (thisRequest !== requestId) return; | ||
| 63 | results.empty(); | ||
| 64 | |||
| 65 | var found; | ||
| 66 | if (renderResults) { | ||
| 67 | found = renderResults(data, results, resultsHeaderHtml); | ||
| 68 | } else { | ||
| 69 | if (data.length && resultsHeaderHtml) { | ||
| 70 | results.append(resultsHeaderHtml); | ||
| 71 | } | ||
| 72 | found = false; | ||
| 73 | for (var i = 0; i < data.length; i++) { | ||
| 74 | (function(node) { | ||
| 75 | var link; | ||
| 76 | if (onSelect) { | ||
| 77 | link = $( | ||
| 78 | "<p><a href='#'>" + node.title + | ||
| 79 | "<span class='result_path'>" + node.unique_name + "</span></a></p>" | ||
| 80 | ); | ||
| 81 | link.find("a").bind("click", function() { | ||
| 82 | onSelect(node); | ||
| 83 | results.slideUp(); | ||
| 84 | results.empty(); | ||
| 85 | return false; | ||
| 86 | }); | ||
| 87 | } else { | ||
| 88 | link = $( | ||
| 89 | "<p><a href='" + node.node_path + "'>" + node.title + | ||
| 90 | "<span class='result_path'>" + node.unique_name + "</span></a></p>" | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | results.append(link); | ||
| 94 | found = true; | ||
| 95 | })(data[i]); | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | if (found) { | ||
| 100 | results.slideDown(); | ||
| 101 | } else { | ||
| 102 | results.slideUp(); | ||
| 103 | } | ||
| 104 | }, | ||
| 105 | error: function(xhr, status, error) { | ||
| 106 | console.log("Ajax error:", status, error, xhr.status, xhr.responseText); | ||
| 107 | } | ||
| 108 | }); | ||
| 109 | } | ||
| 51 | 110 | ||
| 52 | $(inputSelector).bind("input", function() { | 111 | $(inputSelector).bind("input", function() { |
| 53 | if (isActive && !isActive()) return; | 112 | if (isActive && !isActive()) return; |
| 54 | 113 | ||
| 55 | var term = $(this).val(); | 114 | var term = $(this).val(); |
| 56 | var results = $(resultsSelector); | ||
| 57 | clearTimeout(timeout); | 115 | clearTimeout(timeout); |
| 58 | 116 | ||
| 59 | if (!term) { | 117 | if (!term) { |
| @@ -62,63 +120,16 @@ function initSearchPicker(options) { | |||
| 62 | return; | 120 | return; |
| 63 | } | 121 | } |
| 64 | 122 | ||
| 65 | timeout = setTimeout(function() { | 123 | timeout = setTimeout(function() { runSearch(term); }, 250); |
| 66 | var thisRequest = ++requestId; | ||
| 67 | $.ajax({ | ||
| 68 | type: "GET", | ||
| 69 | url: url, | ||
| 70 | data: "search_term=" + term, | ||
| 71 | dataType: "json", | ||
| 72 | success: function(data) { | ||
| 73 | if (thisRequest !== requestId) return; | ||
| 74 | results.empty(); | ||
| 75 | |||
| 76 | var found; | ||
| 77 | if (renderResults) { | ||
| 78 | found = renderResults(data, results, resultsHeaderHtml); | ||
| 79 | } else { | ||
| 80 | if (data.length && resultsHeaderHtml) { | ||
| 81 | results.append(resultsHeaderHtml); | ||
| 82 | } | ||
| 83 | found = false; | ||
| 84 | for (var i = 0; i < data.length; i++) { | ||
| 85 | (function(node) { | ||
| 86 | var link; | ||
| 87 | if (onSelect) { | ||
| 88 | link = $( | ||
| 89 | "<p><a href='#'>" + node.title + | ||
| 90 | "<span class='result_path'>" + node.unique_name + "</span></a></p>" | ||
| 91 | ); | ||
| 92 | link.find("a").bind("click", function() { | ||
| 93 | onSelect(node); | ||
| 94 | results.slideUp(); | ||
| 95 | results.empty(); | ||
| 96 | return false; | ||
| 97 | }); | ||
| 98 | } else { | ||
| 99 | link = $( | ||
| 100 | "<p><a href='" + node.node_path + "'>" + node.title + | ||
| 101 | "<span class='result_path'>" + node.unique_name + "</span></a></p>" | ||
| 102 | ); | ||
| 103 | } | ||
| 104 | results.append(link); | ||
| 105 | found = true; | ||
| 106 | })(data[i]); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | if (found) { | ||
| 111 | results.slideDown(); | ||
| 112 | } else { | ||
| 113 | results.slideUp(); | ||
| 114 | } | ||
| 115 | }, | ||
| 116 | error: function(xhr, status, error) { | ||
| 117 | console.log("Ajax error:", status, error, xhr.status, xhr.responseText); | ||
| 118 | } | ||
| 119 | }); | ||
| 120 | }, 250); | ||
| 121 | }); | 124 | }); |
| 125 | |||
| 126 | if (loadOnFocus) { | ||
| 127 | $(inputSelector).bind("focus", function() { | ||
| 128 | if (isActive && !isActive()) return; | ||
| 129 | if ($(this).val()) return; // the input handler above already covers a real term | ||
| 130 | runSearch(""); | ||
| 131 | }); | ||
| 132 | } | ||
| 122 | } | 133 | } |
| 123 | 134 | ||
| 124 | dashboard_search = { | 135 | dashboard_search = { |
diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb index a3082c2..7f6d487 100644 --- a/test/controllers/related_assets_controller_test.rb +++ b/test/controllers/related_assets_controller_test.rb | |||
| @@ -11,7 +11,7 @@ class RelatedAssetsControllerTest < ActionController::TestCase | |||
| 11 | Asset.create!(:name => "biometrics-poster", :upload_content_type => "image/png") | 11 | Asset.create!(:name => "biometrics-poster", :upload_content_type => "image/png") |
| 12 | Asset.create!(:name => "chaostreff-flyer", :upload_content_type => "image/png") | 12 | Asset.create!(:name => "chaostreff-flyer", :upload_content_type => "image/png") |
| 13 | 13 | ||
| 14 | get :search, params: { :node_id => node.id, :q => "biometrics" } | 14 | get :search, params: { :node_id => node.id, :search_term => "biometrics" } |
| 15 | 15 | ||
| 16 | json = JSON.parse(response.body) | 16 | json = JSON.parse(response.body) |
| 17 | names = json.map { |a| a["name"] } | 17 | names = json.map { |a| a["name"] } |
| @@ -20,13 +20,19 @@ class RelatedAssetsControllerTest < ActionController::TestCase | |||
| 20 | assert_not_includes names, "chaostreff-flyer" | 20 | assert_not_includes names, "chaostreff-flyer" |
| 21 | end | 21 | end |
| 22 | 22 | ||
| 23 | test "search returns an empty list for a blank term" do | 23 | test "search with a blank term returns the most recently created assets" do |
| 24 | Asset.delete_all | ||
| 25 | RelatedAsset.delete_all | ||
| 24 | login_as :quentin | 26 | login_as :quentin |
| 25 | node = Node.root.children.create!(:slug => "related_assets_blank_search_test") | 27 | node = Node.root.children.create!(:slug => "related_assets_recent_test") |
| 26 | 28 | ||
| 27 | get :search, params: { :node_id => node.id, :q => "" } | 29 | Asset.create!(:name => "older-photo", :upload_content_type => "image/png", :created_at => 2.days.ago) |
| 30 | Asset.create!(:name => "newer-photo", :upload_content_type => "image/png", :created_at => 1.hour.ago) | ||
| 28 | 31 | ||
| 29 | assert_equal [], JSON.parse(response.body) | 32 | get :search, params: { :node_id => node.id, :search_term => "" } |
| 33 | |||
| 34 | json = JSON.parse(response.body) | ||
| 35 | assert_equal ["newer-photo", "older-photo"], json.map { |a| a["name"] } | ||
| 30 | end | 36 | end |
| 31 | 37 | ||
| 32 | test "create attaches an asset to the node's editable page" do | 38 | test "create attaches an asset to the node's editable page" do |
