diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-11 23:43:02 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-11 23:43:02 +0200 |
| commit | 92c394b43a0603743b914c6298aab986805ca990 (patch) | |
| tree | 39308770fbae5558d98a7def3d25802ed8083d81 | |
| parent | 47beb3fe6ed6fdb75c2dd3a55362ad1aba3bbc3b (diff) | |
Add drafts/recent/mine/chapters admin node views
Four NodesController actions -- drafts, recent, mine, chapters --
each building its own base scope, sharing one private method
(index_matching) for search narrowing and pagination. Wizard rewrite
to link into these instead of rendering its own tables is a separate,
later step.
Node.editor_search backs the shared "q" narrowing: an ILIKE substring
match against title/abstract on whichever of head or draft is
present, splitting the term on whitespace and requiring every word to
match somewhere independently, not as one phrase, since real words can
end up separated by markup in the underlying HTML. Deliberately
separate from Node.search, the public content search, which stays
tsvector-based and head-only.
chapters generalizes into /admin/nodes/tags/:tags for an arbitrary
tag list (OR'd, not AND'd), sharing the controller action but
rendering its own template rather than branching inside one view.
| -rw-r--r-- | app/controllers/nodes_controller.rb | 45 | ||||
| -rw-r--r-- | app/models/node.rb | 22 | ||||
| -rw-r--r-- | app/views/nodes/_node_list.html.erb | 39 | ||||
| -rw-r--r-- | app/views/nodes/chapters.html.erb | 9 | ||||
| -rw-r--r-- | app/views/nodes/drafts.html.erb | 3 | ||||
| -rw-r--r-- | app/views/nodes/mine.html.erb | 3 | ||||
| -rw-r--r-- | app/views/nodes/recent.html.erb | 3 | ||||
| -rw-r--r-- | app/views/nodes/tags.html.erb | 3 | ||||
| -rw-r--r-- | config/routes.rb | 5 | ||||
| -rw-r--r-- | public/stylesheets/admin.css | 15 | ||||
| -rw-r--r-- | test/controllers/nodes_controller_test.rb | 104 | ||||
| -rw-r--r-- | test/models/node_test.rb | 26 |
12 files changed, 277 insertions, 0 deletions
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 6fcd930..ede91ad 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb | |||
| @@ -183,6 +183,39 @@ class NodesController < ApplicationController | |||
| 183 | render plain: slug_for(params[:title]) | 183 | render plain: slug_for(params[:title]) |
| 184 | end | 184 | end |
| 185 | 185 | ||
| 186 | # Filter functions for admin views | ||
| 187 | def drafts | ||
| 188 | base = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") | ||
| 189 | @nodes = index_matching(base) | ||
| 190 | end | ||
| 191 | |||
| 192 | def recent | ||
| 193 | base = Node.where( | ||
| 194 | "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", | ||
| 195 | Time.now, Time.now - 14.days | ||
| 196 | ) | ||
| 197 | @nodes = index_matching(base) | ||
| 198 | end | ||
| 199 | |||
| 200 | def mine | ||
| 201 | base = Node.joins(:pages) | ||
| 202 | .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) | ||
| 203 | .distinct | ||
| 204 | @nodes = index_matching(base) | ||
| 205 | end | ||
| 206 | |||
| 207 | def chapters | ||
| 208 | @kind_keys = Array(params[:kinds]) & %w[erfa chaostreff] | ||
| 209 | @kind_keys = %w[erfa chaostreff] if @kind_keys.empty? | ||
| 210 | tags = @kind_keys.flat_map { |key| CccConventions::NODE_KINDS[key][:tags] } | ||
| 211 | @nodes = nodes_matching_tags(tags) | ||
| 212 | end | ||
| 213 | |||
| 214 | def tags | ||
| 215 | tags = params[:tags].to_s.split(',').map(&:strip).reject(&:blank?) | ||
| 216 | @nodes = nodes_matching_tags(tags) | ||
| 217 | end | ||
| 218 | |||
| 186 | private | 219 | private |
| 187 | 220 | ||
| 188 | def slug_for(title) | 221 | def slug_for(title) |
| @@ -214,4 +247,16 @@ class NodesController < ApplicationController | |||
| 214 | config && config[:parent] ? config[:parent].call.id : nil | 247 | config && config[:parent] ? config[:parent].call.id : nil |
| 215 | end | 248 | end |
| 216 | end | 249 | end |
| 250 | |||
| 251 | def nodes_matching_tags(tags) | ||
| 252 | matching_pages = Page.tagged_with(tags, any: true).reselect(:id) | ||
| 253 | base = Node.where(head_id: matching_pages).or(Node.where(draft_id: matching_pages)) | ||
| 254 | index_matching(base) | ||
| 255 | end | ||
| 256 | |||
| 257 | def index_matching(base_scope) | ||
| 258 | scope = base_scope.includes(:head, :draft) | ||
| 259 | scope = scope.merge(Node.editor_search(params[:q])) if params[:q].present? | ||
| 260 | scope.order("nodes.updated_at desc").paginate(page: params[:page], per_page: 25) | ||
| 261 | end | ||
| 217 | end | 262 | end |
diff --git a/app/models/node.rb b/app/models/node.rb index 9cb4840..24f3cd0 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -345,6 +345,28 @@ class Node < ApplicationRecord | |||
| 345 | .distinct | 345 | .distinct |
| 346 | end | 346 | end |
| 347 | 347 | ||
| 348 | # This one is for admin-only views, where finding a draft is the point. | ||
| 349 | # Substring match on whichever of head/draft is present. | ||
| 350 | def self.editor_search(term) | ||
| 351 | words = term.to_s.split(/\s+/).reject(&:blank?) | ||
| 352 | return none if words.empty? | ||
| 353 | |||
| 354 | conditions = [] | ||
| 355 | binds = {} | ||
| 356 | |||
| 357 | words.each_with_index do |word, i| | ||
| 358 | key = "term#{i}" | ||
| 359 | binds[key.to_sym] = "%#{sanitize_sql_like(word)}%" | ||
| 360 | conditions << "(head_translations.title ILIKE :#{key} OR head_translations.abstract ILIKE :#{key} " \ | ||
| 361 | "OR draft_translations.title ILIKE :#{key} OR draft_translations.abstract ILIKE :#{key})" | ||
| 362 | end | ||
| 363 | |||
| 364 | joins("LEFT JOIN page_translations head_translations ON head_translations.page_id = nodes.head_id") | ||
| 365 | .joins("LEFT JOIN page_translations draft_translations ON draft_translations.page_id = nodes.draft_id") | ||
| 366 | .where(conditions.join(" AND "), binds) | ||
| 367 | .distinct | ||
| 368 | end | ||
| 369 | |||
| 348 | protected | 370 | protected |
| 349 | def lock_for! current_user | 371 | def lock_for! current_user |
| 350 | self.lock_owner = current_user | 372 | self.lock_owner = current_user |
diff --git a/app/views/nodes/_node_list.html.erb b/app/views/nodes/_node_list.html.erb new file mode 100644 index 0000000..03f38b4 --- /dev/null +++ b/app/views/nodes/_node_list.html.erb | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | <%= form_tag url_for(controller: params[:controller], action: params[:action]), method: :get, class: "node_search_form" do %> | ||
| 2 | <% Array(params[:kinds]).each do |kind| %> | ||
| 3 | <%= hidden_field_tag "kinds[]", kind %> | ||
| 4 | <% end %> | ||
| 5 | <%= hidden_field_tag :tags, params[:tags] if params[:tags].present? %> | ||
| 6 | <%= text_field_tag :q, params[:q], placeholder: "Search title, abstract, body…" %> | ||
| 7 | <%= submit_tag "Search", class: "action_button" %> | ||
| 8 | <% if params[:q].present? || params[:kinds].present? || params[:tags].present? %> | ||
| 9 | <%= link_to "Reset", url_for(controller: params[:controller], action: params[:action]) %> | ||
| 10 | <% end %> | ||
| 11 | <% end %> | ||
| 12 | |||
| 13 | <%= will_paginate @nodes %> | ||
| 14 | <table class="node_table"> | ||
| 15 | <tr class="header"> | ||
| 16 | <th class="node_id">ID</th> | ||
| 17 | <th class="title">Title</th> | ||
| 18 | <th class="actions">Actions</th> | ||
| 19 | <th class="editor">Locked by</th> | ||
| 20 | <th class="revision">Rev.</th> | ||
| 21 | </tr> | ||
| 22 | <% @nodes.each do |node| %> | ||
| 23 | <tr class="<%= cycle("even", "odd") %>"> | ||
| 24 | <td class="node_id"><%= node.id %></td> | ||
| 25 | <td class="title"> | ||
| 26 | <h4><%= link_to title_for_node(node), node_path(node) %></h4> | ||
| 27 | <p><%= link_to_path(node.unique_name, node.unique_name) %></p> | ||
| 28 | </td> | ||
| 29 | <td class="actions"> | ||
| 30 | <%= link_to 'show', node_path(node) %> | ||
| 31 | <%= link_to 'edit', edit_node_path(node) %> | ||
| 32 | <%= link_to 'revisions', node_revisions_path(node) %> | ||
| 33 | </td> | ||
| 34 | <td><%= node.lock_owner.login if node.lock_owner %></td> | ||
| 35 | <td><%= node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY") %></td> | ||
| 36 | </tr> | ||
| 37 | <% end %> | ||
| 38 | </table> | ||
| 39 | <%= will_paginate @nodes %> | ||
diff --git a/app/views/nodes/chapters.html.erb b/app/views/nodes/chapters.html.erb new file mode 100644 index 0000000..939f19c --- /dev/null +++ b/app/views/nodes/chapters.html.erb | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | <h1>Chapters</h1> | ||
| 2 | |||
| 3 | <%= form_tag chapters_nodes_path, method: :get do %> | ||
| 4 | <label><%= check_box_tag 'kinds[]', 'erfa', @kind_keys.include?('erfa') %> Erfa</label> | ||
| 5 | <label><%= check_box_tag 'kinds[]', 'chaostreff', @kind_keys.include?('chaostreff') %> Chaostreff</label> | ||
| 6 | <%= submit_tag "Filter", class: "action_button" %> | ||
| 7 | <% end %> | ||
| 8 | |||
| 9 | <%= render 'node_list' %> | ||
diff --git a/app/views/nodes/drafts.html.erb b/app/views/nodes/drafts.html.erb new file mode 100644 index 0000000..6d0830c --- /dev/null +++ b/app/views/nodes/drafts.html.erb | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | <h1>Nodes with drafts or autosaves</h1> | ||
| 2 | |||
| 3 | <%= render 'node_list' %> | ||
diff --git a/app/views/nodes/mine.html.erb b/app/views/nodes/mine.html.erb new file mode 100644 index 0000000..fc5680a --- /dev/null +++ b/app/views/nodes/mine.html.erb | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | <h1>My work</h1> | ||
| 2 | |||
| 3 | <%= render 'node_list' %> | ||
diff --git a/app/views/nodes/recent.html.erb b/app/views/nodes/recent.html.erb new file mode 100644 index 0000000..3602775 --- /dev/null +++ b/app/views/nodes/recent.html.erb | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | <h1>Recently changed</h1> | ||
| 2 | |||
| 3 | <%= render 'node_list' %> | ||
diff --git a/app/views/nodes/tags.html.erb b/app/views/nodes/tags.html.erb new file mode 100644 index 0000000..0ebc6ce --- /dev/null +++ b/app/views/nodes/tags.html.erb | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | <h1>Nodes tagged: <%= params[:tags] %></h1> | ||
| 2 | |||
| 3 | <%= render 'node_list' %> | ||
diff --git a/config/routes.rb b/config/routes.rb index aebce90..2c165d2 100644 --- a/config/routes.rb +++ b/config/routes.rb | |||
| @@ -37,7 +37,12 @@ Cccms::Application.routes.draw do | |||
| 37 | 37 | ||
| 38 | resources :nodes do | 38 | resources :nodes do |
| 39 | collection do | 39 | collection do |
| 40 | get 'tags/:tags', action: :tags, as: :tags, constraints: { tags: /[^\/]+/ } | ||
| 40 | get :parameterize_preview | 41 | get :parameterize_preview |
| 42 | get :drafts | ||
| 43 | get :recent | ||
| 44 | get :mine | ||
| 45 | get :chapters | ||
| 41 | end | 46 | end |
| 42 | 47 | ||
| 43 | member do | 48 | member do |
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 28b8494..aa8b288 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css | |||
| @@ -748,6 +748,21 @@ form.button_to button[type="submit"] { | |||
| 748 | margin-bottom: 0; | 748 | margin-bottom: 0; |
| 749 | } | 749 | } |
| 750 | 750 | ||
| 751 | .node_search_form { | ||
| 752 | display: flex; | ||
| 753 | align-items: center; | ||
| 754 | gap: 0.5rem; | ||
| 755 | margin-bottom: 0.75rem; | ||
| 756 | } | ||
| 757 | |||
| 758 | .node_search_form input[type=text] { | ||
| 759 | padding: 4px 12px; | ||
| 760 | box-sizing: border-box; | ||
| 761 | height: 2.25rem; | ||
| 762 | width: 22rem; | ||
| 763 | border-radius: 2px; | ||
| 764 | } | ||
| 765 | |||
| 751 | /* Layout only -- the at-rest visibility (wavy underline) for these | 766 | /* Layout only -- the at-rest visibility (wavy underline) for these |
| 752 | links comes from the scoped rule in Base elements above. */ | 767 | links comes from the scoped rule in Base elements above. */ |
| 753 | .add_child_links { | 768 | .add_child_links { |
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index b563d4d..05cb195 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb | |||
| @@ -471,4 +471,108 @@ class NodesControllerTest < ActionController::TestCase | |||
| 471 | assert_response :success | 471 | assert_response :success |
| 472 | assert_select "form.destructive", :count => 0 | 472 | assert_select "form.destructive", :count => 0 |
| 473 | end | 473 | end |
| 474 | |||
| 475 | test "drafts includes a never-published node with only a draft" do | ||
| 476 | node = Node.root.children.create!(:slug => "drafts_action_test") | ||
| 477 | login_as :quentin | ||
| 478 | get :drafts | ||
| 479 | assert_includes assigns(:nodes), node | ||
| 480 | end | ||
| 481 | |||
| 482 | test "chapters filters by kind, matching head or draft, and shows both by default" do | ||
| 483 | erfa_node = Node.root.children.create!(:slug => "chapters_erfa_test") | ||
| 484 | erfa_node.find_or_create_draft(@user1) | ||
| 485 | erfa_node.draft.tag_list = "erfa-detail" | ||
| 486 | erfa_node.draft.save! | ||
| 487 | erfa_node.publish_draft! | ||
| 488 | |||
| 489 | chaostreff_node = Node.root.children.create!(:slug => "chapters_chaostreff_test") | ||
| 490 | chaostreff_node.find_or_create_draft(@user1) | ||
| 491 | chaostreff_node.draft.tag_list = "chaostreff-detail" | ||
| 492 | chaostreff_node.draft.save! | ||
| 493 | chaostreff_node.publish_draft! | ||
| 494 | |||
| 495 | login_as :quentin | ||
| 496 | |||
| 497 | get :chapters, params: { :kinds => "erfa" } | ||
| 498 | assert_includes assigns(:nodes), erfa_node | ||
| 499 | assert_not_includes assigns(:nodes), chaostreff_node | ||
| 500 | |||
| 501 | get :chapters | ||
| 502 | assert_includes assigns(:nodes), erfa_node | ||
| 503 | assert_includes assigns(:nodes), chaostreff_node | ||
| 504 | end | ||
| 505 | |||
| 506 | test "recent combined with a search term does not raise an ambiguous column error" do | ||
| 507 | login_as :quentin | ||
| 508 | get :recent, params: { :q => "Zombies" } | ||
| 509 | assert_response :success | ||
| 510 | end | ||
| 511 | |||
| 512 | test "drafts combined with a search term does not raise an ambiguous column error" do | ||
| 513 | login_as :quentin | ||
| 514 | get :drafts, params: { :q => "Zombies" } | ||
| 515 | assert_response :success | ||
| 516 | end | ||
| 517 | |||
| 518 | test "mine combined with a search term does not raise an ambiguous column error" do | ||
| 519 | login_as :quentin | ||
| 520 | get :mine, params: { :q => "Zombies" } | ||
| 521 | assert_response :success | ||
| 522 | end | ||
| 523 | |||
| 524 | test "chapters combined with a search term does not raise an ambiguous column error" do | ||
| 525 | login_as :quentin | ||
| 526 | get :chapters, params: { :q => "Zombies" } | ||
| 527 | assert_response :success | ||
| 528 | end | ||
| 529 | |||
| 530 | test "tags path filters by an arbitrary raw tag, generalizing chapters" do | ||
| 531 | presse_node = Node.root.children.create!(:slug => "tags_path_presse_test") | ||
| 532 | presse_node.find_or_create_draft(@user1) | ||
| 533 | presse_node.draft.tag_list = "pressemitteilung" | ||
| 534 | presse_node.draft.save! | ||
| 535 | presse_node.publish_draft! | ||
| 536 | |||
| 537 | erfa_node = Node.root.children.create!(:slug => "tags_path_erfa_test") | ||
| 538 | erfa_node.find_or_create_draft(@user1) | ||
| 539 | erfa_node.draft.tag_list = "erfa-detail" | ||
| 540 | erfa_node.draft.save! | ||
| 541 | erfa_node.publish_draft! | ||
| 542 | |||
| 543 | login_as :quentin | ||
| 544 | get :tags, params: { :tags => "pressemitteilung" } | ||
| 545 | |||
| 546 | assert_includes assigns(:nodes), presse_node | ||
| 547 | assert_not_includes assigns(:nodes), erfa_node | ||
| 548 | |||
| 549 | assert_select "h1", "Nodes tagged: pressemitteilung" | ||
| 550 | assert_select "h1", :text => "Chapters", :count => 0 | ||
| 551 | end | ||
| 552 | |||
| 553 | test "tags path with multiple tags matches any of them, not all" do | ||
| 554 | erfa_node = Node.root.children.create!(:slug => "tags_path_multi_erfa_test") | ||
| 555 | erfa_node.find_or_create_draft(@user1) | ||
| 556 | erfa_node.draft.tag_list = "erfa-detail" | ||
| 557 | erfa_node.draft.save! | ||
| 558 | erfa_node.publish_draft! | ||
| 559 | |||
| 560 | chaostreff_node = Node.root.children.create!(:slug => "tags_path_multi_chaostreff_test") | ||
| 561 | chaostreff_node.find_or_create_draft(@user1) | ||
| 562 | chaostreff_node.draft.tag_list = "chaostreff-detail" | ||
| 563 | chaostreff_node.draft.save! | ||
| 564 | chaostreff_node.publish_draft! | ||
| 565 | |||
| 566 | login_as :quentin | ||
| 567 | get :tags, params: {:tags => "erfa-detail,chaostreff-detail" } | ||
| 568 | |||
| 569 | assert_includes assigns(:nodes), erfa_node | ||
| 570 | assert_includes assigns(:nodes), chaostreff_node | ||
| 571 | end | ||
| 572 | |||
| 573 | test "chapters renders the curated heading" do | ||
| 574 | login_as :quentin | ||
| 575 | get :chapters | ||
| 576 | assert_select "h1", "Chapters" | ||
| 577 | end | ||
| 474 | end | 578 | end |
diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 5626384..15e908b 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb | |||
| @@ -511,4 +511,30 @@ class NodeTest < ActiveSupport::TestCase | |||
| 511 | a.reload | 511 | a.reload |
| 512 | assert_equal Node.root.id, a.parent_id | 512 | assert_equal Node.root.id, a.parent_id |
| 513 | end | 513 | end |
| 514 | |||
| 515 | test "editor_search matches a partial substring, not just a whole word" do | ||
| 516 | node = Node.root.children.create!(:slug => "editor_search_substring_test") | ||
| 517 | node.find_or_create_draft(@user1) | ||
| 518 | node.draft.update(:title => "Biometrics Conference") | ||
| 519 | node.publish_draft! | ||
| 520 | |||
| 521 | assert_includes Node.editor_search("bio"), node | ||
| 522 | assert_includes Node.editor_search("Conf"), node | ||
| 523 | end | ||
| 524 | |||
| 525 | test "editor_search returns an empty relation for a blank term, not every node" do | ||
| 526 | assert_equal 0, Node.editor_search("").count | ||
| 527 | assert_equal 0, Node.editor_search(nil).count | ||
| 528 | assert_equal 0, Node.editor_search(" ").count | ||
| 529 | end | ||
| 530 | |||
| 531 | test "editor_search requires every word to match, but each word can match a different field" do | ||
| 532 | node = Node.root.children.create!(:slug => "editor_search_multiword_test") | ||
| 533 | node.find_or_create_draft(@user1) | ||
| 534 | node.draft.update(:title => "Backspace e.V. Bamberg", :abstract => "Spiegelgraben 41, 96052 Bamberg") | ||
| 535 | node.publish_draft! | ||
| 536 | |||
| 537 | assert_includes Node.editor_search("Backspace Spiegelgraben"), node | ||
| 538 | assert_equal 0, Node.editor_search("Backspace Nonexistentstreet").count | ||
| 539 | end | ||
| 514 | end | 540 | end |
