diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-12 16:33:12 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-12 16:33:12 +0200 |
| commit | 4ec70e5ecf792a56e868538738d6c7f63bb6cf24 (patch) | |
| tree | 45364f298ce21a7e47de619b7db79b9b77e4ebc1 | |
| parent | 5803c59192b7fb05840d0b452eb64d9f997f3d8f (diff) | |
Add a grouped tag+node search endpoint for the upcoming dashboard
AdminController#dashboard_search returns tags and nodes as separate,
labeled groups (via ActsAsTaggableOn::Tag.named_like and
Node.editor_search) rather than the flat per-node list every existing
picker returns. initSearchPicker gains a renderResults callback option
that, when given, replaces the default per-node rendering entirely --
lets the dashboard render its two labeled groups without a second,
parallel picker implementation.
resultsHeaderHtml (the "Press Enter to see all results" hint) is now
threaded through as a third argument to renderResults, so a picker with
custom rendering can still show it -- previously only the default
per-node branch ever did.
Tags link straight to the existing /admin/nodes/tags/:tag view rather
than becoming an in-place filter chip.
| -rw-r--r-- | app/controllers/admin_controller.rb | 18 | ||||
| -rw-r--r-- | app/views/layouts/admin.html.erb | 1 | ||||
| -rw-r--r-- | config/routes.rb | 9 | ||||
| -rw-r--r-- | public/javascripts/admin_interface.js | 4 | ||||
| -rw-r--r-- | public/javascripts/admin_search.js | 109 | ||||
| -rw-r--r-- | public/stylesheets/admin.css | 10 | ||||
| -rw-r--r-- | test/controllers/admin_controller_test.rb | 24 |
7 files changed, 139 insertions, 36 deletions
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 3c45c49..8445997 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb | |||
| @@ -49,6 +49,24 @@ class AdminController < ApplicationController | |||
| 49 | end | 49 | end |
| 50 | end | 50 | end |
| 51 | 51 | ||
| 52 | def dashboard_search | ||
| 53 | term = params[:search_term] | ||
| 54 | |||
| 55 | if term.blank? | ||
| 56 | render json: { tags: [], nodes: [] } | ||
| 57 | return | ||
| 58 | end | ||
| 59 | |||
| 60 | render json: { | ||
| 61 | tags: ActsAsTaggableOn::Tag.named_like(term).limit(5).map { |tag| | ||
| 62 | { name: tag.name, tag_path: tags_nodes_path(tags: tag.name) } | ||
| 63 | }, | ||
| 64 | nodes: Node.editor_search(term).limit(10).map { |node| | ||
| 65 | { node_id: node.id, title: node.title, unique_name: node.unique_name, node_path: node_path(node) } | ||
| 66 | } | ||
| 67 | } | ||
| 68 | end | ||
| 69 | |||
| 52 | def menu_search | 70 | def menu_search |
| 53 | if params[:search_term] == "Root" | 71 | if params[:search_term] == "Root" |
| 54 | @results = [Node.root] | 72 | @results = [Node.root] |
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 079346b..00d7075 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb | |||
| @@ -17,6 +17,7 @@ | |||
| 17 | var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; | 17 | var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; |
| 18 | var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; | 18 | var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; |
| 19 | var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; | 19 | var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; |
| 20 | var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; | ||
| 20 | </script> | 21 | </script> |
| 21 | </head> | 22 | </head> |
| 22 | 23 | ||
diff --git a/config/routes.rb b/config/routes.rb index bb34bd8..92301e5 100644 --- a/config/routes.rb +++ b/config/routes.rb | |||
| @@ -66,10 +66,11 @@ Cccms::Application.routes.draw do | |||
| 66 | end | 66 | end |
| 67 | end | 67 | end |
| 68 | 68 | ||
| 69 | match '' => 'admin#index', :as => :admin, :via => :get | 69 | match '' => 'admin#index', :as => :admin, :via => :get |
| 70 | match 'search' => 'admin#search', :as => :admin_search, :via => :get | 70 | match 'search' => 'admin#search', :as => :admin_search, :via => :get |
| 71 | match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get | 71 | match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get |
| 72 | match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get | 72 | match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get |
| 73 | match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get | ||
| 73 | end | 74 | end |
| 74 | 75 | ||
| 75 | match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete | 76 | match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete |
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 8ca5b5e..3f6a0a9 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js | |||
| @@ -31,6 +31,10 @@ $(document).ready(function () { | |||
| 31 | } | 31 | } |
| 32 | }); | 32 | }); |
| 33 | 33 | ||
| 34 | if ($("#dashboard_search_term").length != 0) { | ||
| 35 | dashboard_search.initialize(); | ||
| 36 | } | ||
| 37 | |||
| 34 | if ($("#menu_search_term").length != 0) { | 38 | if ($("#menu_search_term").length != 0) { |
| 35 | menu_items.initialize_search(); | 39 | menu_items.initialize_search(); |
| 36 | } | 40 | } |
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index f553334..53bcb5e 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js | |||
| @@ -42,9 +42,10 @@ 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; // omit for a real-navigation search like admin_search |
| 46 | var isActive = options.isActive; // optional guard, checked before every search | 46 | var isActive = options.isActive; // optional guard, checked before every search |
| 47 | var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist | 47 | var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist |
| 48 | var renderResults = options.renderResults; // optional, replaces the default per-node rendering entirely | ||
| 48 | var requestId = 0; | 49 | var requestId = 0; |
| 49 | var timeout; | 50 | var timeout; |
| 50 | 51 | ||
| @@ -68,39 +69,44 @@ function initSearchPicker(options) { | |||
| 68 | url: url, | 69 | url: url, |
| 69 | data: "search_term=" + term, | 70 | data: "search_term=" + term, |
| 70 | dataType: "json", | 71 | dataType: "json", |
| 71 | success: function(nodes) { | 72 | success: function(data) { |
| 72 | if (thisRequest !== requestId) return; | 73 | if (thisRequest !== requestId) return; |
| 73 | results.empty(); | 74 | results.empty(); |
| 74 | if (nodes.length && resultsHeaderHtml) { | 75 | |
| 75 | results.append(resultsHeaderHtml); | 76 | var found; |
| 76 | } | 77 | if (renderResults) { |
| 77 | var found = false; | 78 | found = renderResults(data, results, resultsHeaderHtml); |
| 78 | for (var i = 0; i < nodes.length; i++) { | 79 | } else { |
| 79 | (function(node) { | 80 | if (data.length && resultsHeaderHtml) { |
| 80 | var link; | 81 | results.append(resultsHeaderHtml); |
| 81 | if (onSelect) { | 82 | } |
| 82 | link = $( | 83 | found = false; |
| 83 | "<p><a href='#'>" + node.title + | 84 | for (var i = 0; i < data.length; i++) { |
| 84 | "<span class='result_path'>" + node.unique_name + "</span>" + | 85 | (function(node) { |
| 85 | "</a></p>" | 86 | var link; |
| 86 | ); | 87 | if (onSelect) { |
| 87 | link.find("a").bind("click", function() { | 88 | link = $( |
| 88 | onSelect(node); | 89 | "<p><a href='#'>" + node.title + |
| 89 | results.slideUp(); | 90 | "<span class='result_path'>" + node.unique_name + "</span></a></p>" |
| 90 | results.empty(); | 91 | ); |
| 91 | return false; | 92 | link.find("a").bind("click", function() { |
| 92 | }); | 93 | onSelect(node); |
| 93 | } else { | 94 | results.slideUp(); |
| 94 | link = $( | 95 | results.empty(); |
| 95 | "<p><a href='" + node.node_path + "'>" + node.title + | 96 | return false; |
| 96 | "<span class='result_path'>" + node.unique_name + "</span>" + | 97 | }); |
| 97 | "</a></p>" | 98 | } else { |
| 98 | ); | 99 | link = $( |
| 99 | } | 100 | "<p><a href='" + node.node_path + "'>" + node.title + |
| 100 | results.append(link); | 101 | "<span class='result_path'>" + node.unique_name + "</span></a></p>" |
| 101 | found = true; | 102 | ); |
| 102 | })(nodes[i]); | 103 | } |
| 104 | results.append(link); | ||
| 105 | found = true; | ||
| 106 | })(data[i]); | ||
| 107 | } | ||
| 103 | } | 108 | } |
| 109 | |||
| 104 | if (found) { | 110 | if (found) { |
| 105 | results.slideDown(); | 111 | results.slideDown(); |
| 106 | } else { | 112 | } else { |
| @@ -115,6 +121,45 @@ function initSearchPicker(options) { | |||
| 115 | }); | 121 | }); |
| 116 | } | 122 | } |
| 117 | 123 | ||
| 124 | dashboard_search = { | ||
| 125 | initialize : function() { | ||
| 126 | initSearchPicker({ | ||
| 127 | inputSelector: "#dashboard_search_term", | ||
| 128 | resultsSelector: "#dashboard_search_results", | ||
| 129 | url: DASHBOARD_SEARCH_URL, | ||
| 130 | resultsHeaderHtml: "<p class='search_more'>Press Enter to see all results ⏎</p>", | ||
| 131 | renderResults: function(data, results, resultsHeaderHtml) { | ||
| 132 | var found = false; | ||
| 133 | |||
| 134 | if ((data.tags.length || data.nodes.length) && resultsHeaderHtml) { | ||
| 135 | results.append(resultsHeaderHtml); | ||
| 136 | } | ||
| 137 | |||
| 138 | if (data.tags.length) { | ||
| 139 | results.append("<p class='search_group_label'>Tags</p>"); | ||
| 140 | data.tags.forEach(function(tag) { | ||
| 141 | results.append("<p><a href='" + tag.tag_path + "'>" + tag.name + "</a></p>"); | ||
| 142 | found = true; | ||
| 143 | }); | ||
| 144 | } | ||
| 145 | |||
| 146 | if (data.nodes.length) { | ||
| 147 | results.append("<p class='search_group_label'>Pages</p>"); | ||
| 148 | data.nodes.forEach(function(node) { | ||
| 149 | results.append( | ||
| 150 | "<p><a href='" + node.node_path + "'>" + node.title + | ||
| 151 | "<span class='result_path'>" + node.unique_name + "</span></a></p>" | ||
| 152 | ); | ||
| 153 | found = true; | ||
| 154 | }); | ||
| 155 | } | ||
| 156 | |||
| 157 | return found; | ||
| 158 | } | ||
| 159 | }); | ||
| 160 | } | ||
| 161 | }; | ||
| 162 | |||
| 118 | menu_items = { | 163 | menu_items = { |
| 119 | initialize_search : function() { | 164 | initialize_search : function() { |
| 120 | initSearchPicker({ | 165 | initSearchPicker({ |
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index da31535..ade3a62 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css | |||
| @@ -1052,6 +1052,16 @@ div#draft_list table td.actions a { | |||
| 1052 | border-bottom: none; | 1052 | border-bottom: none; |
| 1053 | } | 1053 | } |
| 1054 | 1054 | ||
| 1055 | .search_results p.search_group_label { | ||
| 1056 | margin: 8px 0 2px; | ||
| 1057 | padding: 0; | ||
| 1058 | border-bottom: none; | ||
| 1059 | font-size: 0.75rem; | ||
| 1060 | color: #969696; | ||
| 1061 | text-transform: lowercase; | ||
| 1062 | font-weight: normal; | ||
| 1063 | } | ||
| 1064 | |||
| 1055 | /* ============================================================ | 1065 | /* ============================================================ |
| 1056 | Menu items (navigation editor) | 1066 | Menu items (navigation editor) |
| 1057 | ============================================================ */ | 1067 | ============================================================ */ |
diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 13cc1bb..9beaf58 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb | |||
| @@ -34,4 +34,28 @@ class AdminControllerTest < ActionController::TestCase | |||
| 34 | matches = assigns(:mynodes).select { |n| n.id == node.id } | 34 | matches = assigns(:mynodes).select { |n| n.id == node.id } |
| 35 | assert_equal 1, matches.length | 35 | assert_equal 1, matches.length |
| 36 | end | 36 | end |
| 37 | |||
| 38 | test "dashboard_search returns matching tags and nodes grouped separately" do | ||
| 39 | node = Node.root.children.create!(:slug => "dashboard_search_test") | ||
| 40 | node.find_or_create_draft(User.find_by_login("aaron")) | ||
| 41 | node.draft.update(:title => "Biometrics Workshop") | ||
| 42 | node.draft.tag_list = "biometrics-workshop" | ||
| 43 | node.draft.save! | ||
| 44 | |||
| 45 | login_as :quentin | ||
| 46 | get :dashboard_search, params: { :search_term => "biometr" }, :format => :json | ||
| 47 | |||
| 48 | json = JSON.parse(response.body) | ||
| 49 | assert json["tags"].any? { |t| t["name"] == "biometrics-workshop" } | ||
| 50 | assert json["nodes"].any? { |n| n["title"] == "Biometrics Workshop" } | ||
| 51 | end | ||
| 52 | |||
| 53 | test "dashboard_search returns empty results for a blank term" do | ||
| 54 | login_as :quentin | ||
| 55 | get :dashboard_search, params: { :search_term => "" }, :format => :json | ||
| 56 | |||
| 57 | json = JSON.parse(response.body) | ||
| 58 | assert_equal [], json["tags"] | ||
| 59 | assert_equal [], json["nodes"] | ||
| 60 | end | ||
| 37 | end | 61 | end |
