summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-11 23:43:02 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-11 23:43:02 +0200
commit92c394b43a0603743b914c6298aab986805ca990 (patch)
tree39308770fbae5558d98a7def3d25802ed8083d81
parent47beb3fe6ed6fdb75c2dd3a55362ad1aba3bbc3b (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.rb45
-rw-r--r--app/models/node.rb22
-rw-r--r--app/views/nodes/_node_list.html.erb39
-rw-r--r--app/views/nodes/chapters.html.erb9
-rw-r--r--app/views/nodes/drafts.html.erb3
-rw-r--r--app/views/nodes/mine.html.erb3
-rw-r--r--app/views/nodes/recent.html.erb3
-rw-r--r--app/views/nodes/tags.html.erb3
-rw-r--r--config/routes.rb5
-rw-r--r--public/stylesheets/admin.css15
-rw-r--r--test/controllers/nodes_controller_test.rb104
-rw-r--r--test/models/node_test.rb26
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
217end 262end
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
474end 578end
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
514end 540end