diff options
| -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 |
