From 206dc5c50a73c5402b90d7fdc8945d3ba9356758 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 4 Jul 2026 01:24:55 +0200 Subject: Add self-service event creation from nodes#show nodes#show's events table now renders unconditionally (previously hidden entirely for a zero-event node) and gains an "add event" link. The suggested tag_list is derived from the page's own category tags via NodesHelper::DEFAULT_EVENT_TAG_BY_PAGE_TAG (erfa-detail/ chaostreff-detail -> open-day) rather than hardcoded, and degrades to a blank field for any node whose tags don't match - deliberately universal, not chapter-specific, since Updates have historically carried event dates the same way. events#new surfaces *why* the tag was pre-filled via flash.now (not flash - this is a same-request render, not a redirect), using an explicit auto_tag_source param passed alongside tag_list rather than having the controller re-derive the reason from node_id. No destroy link added to nodes#show's events list - deliberate, per existing subnav-semantics convention (destructive actions live on the resource's own views). Covered directly by test. Adds real coverage for EventsController, previously 100% commented-out scaffold, plus unit tests for the new tag-mapping helper and the weekday-abbreviation helpers from the prior commit. --- test/controllers/nodes_controller_test.rb | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 53799f1..f14e27c 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -379,4 +379,37 @@ class NodesControllerTest < ActionController::TestCase get :index end + test "show renders events row and add-link for zero-event chapter node" do + login_as :quentin + node = create_node_with_published_page + node.head.tag_list = "erfa-detail" + node.head.save! + + get :show, params: { id: node.id } + assert_response :success + assert_select "a", text: "add event" + assert_select "a[href*='tag_list=open-day']" + assert_select "a[href*='auto_tag_source=erfa-detail']" + end + + test "show renders events row without a tag default for untagged node" do + login_as :quentin + node = create_node_with_published_page + + get :show, params: { id: node.id } + assert_response :success + assert_select "a", text: "add event" + assert_select "a[href*='tag_list=']", count: 0 + end + + test "show never renders a destroy link for events" do + login_as :quentin + node = create_node_with_published_page + Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :show, params: { id: node.id } + assert_response :success + assert_select "form.button_to.destructive", count: 0 + end + end -- cgit v1.3 From 394a2b890686cadd7a3ecb0038ce5d0b744431f4 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 04:34:04 +0200 Subject: Strengthen event/occurrence cascade test with real assertions test_can_remove_a_node_with_an_event previously just called node.destroy and get :index with no assertions at all - would pass whether or not occurrences were actually cleaned up, or even if index rendered correctly afterward. Now confirms occurrences genuinely exist before destroy (otherwise a passing post-destroy count of zero is meaningless - indistinguishable from "nothing to cascade in the first place"), scopes the count to this event specifically rather than a global Occurrence.count that could coincidentally pass regardless of whether this cascade works, and checks the trailing index request actually succeeds rather than just not raising. First real test of the occurrences.event_id FK constraint added earlier this session, not just the application-level dependent: :destroy. --- test/controllers/nodes_controller_test.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index f14e27c..99e06ae 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -364,7 +364,7 @@ class NodesControllerTest < ActionController::TestCase test "can remove a node with an event" do node = create_node_with_published_page - Event.create!( + event = Event.create!( :start_time => "2009-01-01T15:23:42".to_time, :end_time => "2009-01-01T20:05:23".to_time, :url => "http://events.ccc.de/congress/2082", @@ -373,10 +373,16 @@ class NodesControllerTest < ActionController::TestCase :allday => true, :node_id => node.id ) + event_id = event.id + assert_operator Occurrence.where(event_id: event_id).count, :>, 0, "expected the event to have generated at least one occurrence before destroy" + node.destroy + assert_equal 0, Occurrence.where(event_id: event_id).count + login_as :quentin get :index + assert_response :success end test "show renders events row and add-link for zero-event chapter node" do -- cgit v1.3 From 1393b3de31d95b1aa5122d6da37bd3259830bdb6 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 16:51:27 +0200 Subject: Fix test to match the current layout --- test/controllers/nodes_controller_test.rb | 126 +++++++++++++++--------------- 1 file changed, 63 insertions(+), 63 deletions(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 99e06ae..61f7db4 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -9,13 +9,13 @@ class NodesControllerTest < ActionController::TestCase get :index assert_response :success end - + def test_new login_as :quentin get :new assert_response :success end - + test "create generic node with parent_id provided" do login_as :quentin before_count = Node.count @@ -33,7 +33,7 @@ class NodesControllerTest < ActionController::TestCase assert_equal Node.last.parent_id, Node.root.id assert_equal 1, Node.last.level end - + test "create update node" do login_as :quentin post( @@ -45,7 +45,7 @@ class NodesControllerTest < ActionController::TestCase ) assert_response :redirect end - + test "create top level node" do login_as :quentin before_count = Node.count @@ -62,64 +62,64 @@ class NodesControllerTest < ActionController::TestCase assert_equal expected, Node.last.unique_name assert_equal 1, Node.last.level end - + test "creating a top_level node without a title should not work" do login_as :quentin - + assert_no_difference "Node.count" do post(:create, params: { :kind => "top_level" } ) end end - + test "creating a generic node without a parent_id should not work" do login_as :quentin - + assert_no_difference "Node.count" do post(:create, params: { :kind => "generic" } ) end end - + test "editing a node" do login_as :quentin - + node = Node.find_by_unique_name("fourth_child") node.pages.create node.draft = node.pages.last node.save - + assert_equal 1, node.pages.length - + draft = node.find_or_create_draft( User.first ) draft.title = "Hello" draft.body = "World" draft.save node.publish_draft! - + get :edit, params: { :id => node.id } assert_response :success assert_select("#page_title[value='Hello']") assert_select("#page_body", "World") - + node.reload assert_equal 2, node.pages.length assert_equal "Hello", node.find_or_create_draft( User.first ).title assert_equal "World", node.find_or_create_draft( User.first ).body end - + test "editing a locked node raises LockedByAnotherUser Exception" do login_as :quentin - + node = create_node_with_draft node.lock_owner = User.last node.save - + assert node.locked? - + get :edit, params: { :id => node.id } assert_response :redirect assert flash[:error] =~ /Page is locked by another user/ end - + def test_update_a_draft test_node = Node.root.children.create! :slug => "test_node" login_as :quentin @@ -128,7 +128,7 @@ class NodesControllerTest < ActionController::TestCase assert_equal "Hello", test_node.draft.title assert_equal "There", test_node.draft.body end - + def test_update_a_draft_with_changing_the_template test_node = Node.root.children.create! :slug => "test_node" @@ -148,19 +148,19 @@ class NodesControllerTest < ActionController::TestCase assert_equal "There", test_node.head.body assert_equal "Foobar", test_node.head.template_name end - + test "publish draft with staged_slug unqueal slug" do login_as :quentin - + test_node = Node.root.children.create! :slug => "test_node", :staged_slug => "peter_pan" - + put :publish, params: { :id => test_node.id } - + test_node.reload assert_equal "peter_pan", test_node.slug assert_equal "peter_pan", test_node.unique_name end - + test "publish draft with staged_slug with more levels of nodes" do login_as :quentin @@ -168,12 +168,12 @@ class NodesControllerTest < ActionController::TestCase test_node2 = test_node.children.create! :slug => "test_node2" put :publish, params: { :id => test_node.id } - + test_node.reload; test_node2.reload assert_equal "peter_pan/test_node2", test_node2.unique_name assert_equal "peter_pan", test_node.unique_name end - + test "publish draft with staged_parent_id" do login_as :quentin @@ -187,77 +187,77 @@ class NodesControllerTest < ActionController::TestCase assert_equal "parent/test_node", test_node.unique_name assert_equal "parent/test_node/test_node2", test_node2.unique_name end - + test "publish draft with staged_parent_id and staged_slug" do login_as :quentin - + parent = Node.root.children.create! :slug => "parent" - + test_node = Node.root.children.create!( - :slug => "test_node", + :slug => "test_node", :staged_parent_id => parent.id, :staged_slug => "peter_pan" ) - + test_node2 = test_node.children.create! :slug => "test_node2" - + put :publish, params: { :id => test_node.id } - + test_node.reload; test_node2.reload assert_equal "parent/peter_pan", test_node.unique_name assert_equal "parent/peter_pan/test_node2", test_node2.unique_name end - + test "show node with empty draft" do login_as :quentin assert_not_nil node = create_node_with_draft get :show, params: { :id => node.id } assert_response :success end - + test "show node with published draft" do login_as :quentin node = create_node_with_published_page get :show, params: { :id => node.id } assert_response :success - assert_select "td", :text => "Test", :count => 3 + assert_select "div.node_content", :text => "Test", :count => 3 end - + test "unlocking a locked node" do login_as :quentin node = create_node_with_published_page node.find_or_create_draft User.first - + assert node.locked? - + put :unlock, params: { :id => node.id } assert_response :redirect assert !node.reload.locked? end - + test "unlocking an already unlocked node" do login_as :quentin node = create_node_with_published_page - + put :unlock, params: { :id => node.id } assert_response :redirect assert_equal "Already unlocked", flash[:notice] end - + test "updating a node by changing its parent" do Node.root.descendants.destroy_all login_as :quentin node = create_node_with_published_page node.find_or_create_draft User.first - + other_node = Node.root.children.create( :slug => "other" ) - + node.staged_parent_id = other_node.id node.publish_draft! assert Node.valid? end - + test "editing the initial draft sets the author to current_user" do login_as :quentin Node.root.descendants.destroy_all @@ -266,7 +266,7 @@ class NodesControllerTest < ActionController::TestCase node.reload assert_equal "quentin", node.draft.user.login end - + test "updating the author of a node with existing head" do login_as :quentin Node.root.descendants.destroy_all @@ -275,70 +275,70 @@ class NodesControllerTest < ActionController::TestCase node.find_or_create_draft users(:quentin) assert node.draft.valid? assert node.valid? - + put :update, params: { :id => node.id, :page => {:user_id => users(:aaron).id} } assert_response :redirect assert_equal "aaron", node.reload.draft.user.login end - + test "updating an existing page should not modify published_at" do login_as :quentin Node.root.descendants.destroy_all node = create_node_with_published_page - + get :edit, params: { :id => node.id } assert_response :success - + put :publish, params: { :id => node.id } - + node.reload assert_equal node.pages[0].published_at, node.pages[1].published_at end - + test "updating an exisiting page should not alter the author" do login_as :aaron Node.root.descendants.destroy_all node = create_node_with_published_page get :edit, params: { :id => node.id } - + put :publish, params: { :id => node.id } - + node.reload assert_equal node.pages[0].user, node.pages[1].user end - + test "editor and author are the same on a new node" do login_as :quentin node = create_node_with_draft get :edit, params: { :id => node.id } - + node.reload assert_equal "quentin", node.draft.user.login assert_equal "quentin", node.draft.editor.login end - + test "creating new draft alters the editor but keeps the author" do node = create_node_with_published_page assert_equal "quentin", node.head.user.login - + login_as :aaron get :edit, params: {:id => node.id } - + node.reload assert_equal "quentin", node.head.user.login assert_equal "aaron", node.draft.editor.login end - + test "unlocking and relocking changes editor if done by another user" do node = create_node_with_published_page draft = node.find_or_create_draft users(:quentin) assert_equal draft.user.login, draft.editor.login assert node.locked? node.unlock! - + login_as :aaron get :edit, params: { :id => node.id } - + node.reload assert_equal "quentin", node.draft.user.login assert_equal "aaron", node.draft.editor.login -- cgit v1.3 From c6bf63a82007c275d13e9e9e0857434b3b7890c0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 16:23:01 +0200 Subject: Fix tests for the new autosave routing Route Save and autosave through the new Node methods update now promotes the current autosave into the draft via save_draft! rather than writing submitted params directly; autosave gets its own PUT member route so PATCH update can mean "deliberate save" specifically, matching every other custom action on this resource. Both now require the lock to already be held rather than acquiring it themselves, which is a deliberate narrowing: through the real UI this is always true, since neither can fire before edit has loaded. Two existing tests called update directly with no prior edit, which the old code tolerated by acquiring the lock as a side effect; updated to call edit first instead of loosening the guarantee back open. NodesController#edit itself is unchanged and still calls the old find_or_create_draft, so drafts are still created eagerly on entering edit for now -- switching that over is the next step, not this one. --- app/controllers/nodes_controller.rb | 39 +++++++++++++++++++++++-------- test/controllers/nodes_controller_test.rb | 2 ++ 2 files changed, 31 insertions(+), 10 deletions(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index a72be68..042963b 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -11,7 +11,8 @@ class NodesController < ApplicationController :update, :destroy, :publish, - :unlock + :unlock, + :autosave ] def index @@ -72,17 +73,35 @@ class NodesController < ApplicationController def update @node.update(node_params) - @draft = @node.find_or_create_draft current_user - @draft.tag_list = params[:tag_list] - if @draft.update( page_params ) - flash[:notice] = "Draft has been saved: #{Time.now}" - respond_to do |format| - format.html { redirect_to edit_node_path(@node) } - format.js - end + @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user ) + @node.save_draft!(current_user) + + flash[:notice] = "Draft has been saved: #{Time.now}" + + if params[:commit] == "Save + Unlock + Exit" + @node.unlock! + redirect_to node_path(@node) else - render :action => :edit + redirect_to edit_node_path(@node) end + rescue LockedByAnotherUser => e + flash[:error] = e.message + redirect_to node_path(@node) + rescue ActiveRecord::RecordInvalid + @page = @node.autosave || @node.draft || @node.head + render :action => :edit + end + + def autosave + @node.update(node_params) + @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user ) + head :ok + rescue LockedByAnotherUser => e + render plain: e.message, status: :locked + rescue ActiveRecord::RecordInvalid => e + render plain: e.message, status: :unprocessable_entity + rescue StandardError => e + render plain: "Autosave failed", status: :internal_server_error end def destroy diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 61f7db4..f0be8c9 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -123,6 +123,7 @@ class NodesControllerTest < ActionController::TestCase def test_update_a_draft test_node = Node.root.children.create! :slug => "test_node" login_as :quentin + get :edit, params: { :id => test_node.id } put :update, params: { :id => test_node.id, :page => {:title => "Hello", :body => "There"} } test_node.reload assert_equal "Hello", test_node.draft.title @@ -133,6 +134,7 @@ class NodesControllerTest < ActionController::TestCase test_node = Node.root.children.create! :slug => "test_node" login_as :quentin + get :edit, params: { :id => test_node.id } put :update, params: { :id => test_node.id, :page => { -- cgit v1.3 From a5d7fb9730aa629b3eb8244ee1b3dd863fbfbed5 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 16:24:52 +0200 Subject: Fix node_content count in show-with-published-draft test Fallout from the earlier nodes#show heading change, which replaced the Title row with an

-- unrelated to the autosave work in the preceding commits. --- test/controllers/nodes_controller_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index f0be8c9..030cff0 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -222,7 +222,7 @@ class NodesControllerTest < ActionController::TestCase node = create_node_with_published_page get :show, params: { :id => node.id } assert_response :success - assert_select "div.node_content", :text => "Test", :count => 3 + assert_select "div.node_content", :text => "Test", :count => 2 end test "unlocking a locked node" do -- cgit v1.3 From 6ad96c44d04df01e6abde097c681e824dd5fe745 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 17:31:15 +0200 Subject: Fix authorship and published_at loss in autosave promotion Two real bugs surfaced by the full test suite, not by the new tests written for this feature -- both were latent the moment lock_for_editing! and save_draft! replaced find_or_create_draft, and only visible once an existing test exercised the exact path each one lived in. lock_for_editing! never stamped user/editor onto a draft that already existed when the lock was acquired. find_or_create_draft used to do this as part of acquiring the lock; splitting lock acquisition from draft creation dropped it entirely, since it looked like draft-creation logic rather than locking logic. Restored as an explicit step: claim authorship only if none is set yet, editorship unconditionally, matching the old behavior exactly. save_draft!'s "no draft yet" branch set user/editor before calling clone_attributes_from, whose first line is an unconditional self.reload -- silently discarding both, since neither had been persisted yet. clone_attributes_from also always copies published_at from its source without the ||= guard used for template_name, and the source here is always the autosave, whose published_at is never anything but nil -- meaning every single promotion, not just the first, was quietly resetting a published page's published_at, which publish_draft!'s own ||= Time.now would then treat as never-published and re-stamp. Fixed by running clone_attributes_from first on both branches, then applying user/editor/published_at afterward, exactly as the existing-draft branch already happened to do by accident. Four controller tests updated to insert a real put :update between get :edit and assertions that used to be true immediately after visiting edit -- deferred draft creation means edit alone no longer produces one, which is the intended consequence of this whole redesign, not something these tests were meant to catch. --- app/models/node.rb | 36 ++++++++++++++++++++++++++++--- test/controllers/nodes_controller_test.rb | 7 ++++-- test/models/node_test.rb | 3 +++ 3 files changed, 41 insertions(+), 5 deletions(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/app/models/node.rb b/app/models/node.rb index 9eb0fe4..f15c908 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -56,6 +56,11 @@ class Node < ApplicationRecord def lock_for_editing! current_user if self.lock_owner.nil? || self.lock_owner == current_user lock_for! current_user + if self.draft + self.draft.user = current_user if self.draft.user.nil? + self.draft.editor = current_user + self.draft.save! + end self else raise( @@ -95,15 +100,18 @@ class Node < ApplicationRecord return unless self.autosave if self.draft + preserved_published_at = self.draft.published_at self.draft.clone_attributes_from self.autosave + self.draft.published_at = preserved_published_at self.draft.user_id = self.autosave.user_id if self.autosave.user_id self.draft.editor = current_user self.draft.save! else - empty_page = self.pages.create! - empty_page.user = self.autosave.user_id ? self.autosave.user : (self.head ? self.head.user : current_user) - empty_page.editor = current_user + empty_page = self.pages.create! empty_page.clone_attributes_from self.autosave + empty_page.user = self.autosave.user_id ? self.autosave.user : (self.head ? self.head.user : current_user) + empty_page.editor = current_user + empty_page.published_at = self.head.published_at if self.head empty_page.save! self.draft = empty_page self.save! @@ -150,6 +158,28 @@ class Node < ApplicationRecord self.draft.reload end + # Discards exactly the topmost non-empty layer -- autosave if present, + # else draft -- and reveals whatever's beneath it. Releases the lock + # only once nothing is left to protect (no draft survives); leaves it + # alone whenever a draft remains, since #edit still has real content + # open. + def revert! current_user + assert_locked_by! current_user + + if self.autosave + self.autosave.destroy + self.autosave_id = nil + self.save! + elsif self.draft && self.head + self.draft.destroy + self.draft_id = nil + self.save! + end + + self.unlock! unless self.draft + self.reload + end + def staged_slug=(value) if head.blank? self.slug = value diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 030cff0..f3e04c2 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -101,7 +101,7 @@ class NodesControllerTest < ActionController::TestCase assert_select("#page_body", "World") node.reload - assert_equal 2, node.pages.length + assert_equal 1, node.pages.length assert_equal "Hello", node.find_or_create_draft( User.first ).title assert_equal "World", node.find_or_create_draft( User.first ).body end @@ -291,6 +291,7 @@ class NodesControllerTest < ActionController::TestCase get :edit, params: { :id => node.id } assert_response :success + put :update, params: { :id => node.id, :page => { :title => "updated" } } put :publish, params: { :id => node.id } node.reload @@ -303,6 +304,7 @@ class NodesControllerTest < ActionController::TestCase node = create_node_with_published_page get :edit, params: { :id => node.id } + put :update, params: { :id => node.id, :page => { :title => "updated" } } put :publish, params: { :id => node.id } node.reload @@ -324,7 +326,8 @@ class NodesControllerTest < ActionController::TestCase assert_equal "quentin", node.head.user.login login_as :aaron - get :edit, params: {:id => node.id } + get :edit, params: { :id => node.id } + put :update, params: { :id => node.id, :page => { :title => "updated" } } node.reload assert_equal "quentin", node.head.user.login diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 2953f8f..bdf556d 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -339,6 +339,9 @@ class NodeTest < ActiveSupport::TestCase assert_equal head_revision, node.head.revision assert_nil node.autosave assert_equal 2, node.pages.count + assert_equal node.head.user, node.draft.user + assert_equal @user1, node.draft.editor + assert_equal node.head.published_at, node.draft.published_at end test "autosave!, save_draft!, and lock_for_editing! raise LockedByAnotherUser for a second user" do -- cgit v1.3 From c2b2648d327e1c1749c37fe2e58cd051ed871547 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 02:13:02 +0200 Subject: Destroying Draft or Discarding Autosave drops you where you left --- app/controllers/nodes_controller.rb | 5 +++- app/views/nodes/show.html.erb | 1 + app/views/revisions/diff.html.erb | 1 + test/controllers/nodes_controller_test.rb | 39 +++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index d1538e1..6fcd930 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -128,7 +128,10 @@ class NodesController < ApplicationController def revert @node.lock_for_editing!(current_user) @node.revert!(current_user) - if @node.draft + + if params[:return_to].present? + redirect_to safe_return_to(params[:return_to]) + elsif @node.draft redirect_to edit_node_path(@node) else redirect_to node_path(@node) diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 2469310..8b9e98b 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -53,6 +53,7 @@
<%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, + params: { return_to: request.path }, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %>
<% end %> diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index 3157dca..490cf17 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -36,6 +36,7 @@ <% if !@locked_by_other && (@node.autosave || @node.draft) %> <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, + params: { return_to: request.fullpath }, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> <% end %>

diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index f3e04c2..ce3419c 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -423,4 +423,43 @@ class NodesControllerTest < ActionController::TestCase assert_select "form.button_to.destructive", count: 0 end + test "reverting from nodes#show returns to the show page, not the editor, even if a draft remains" do + user = User.find_by_login("aaron") + node = Node.root.children.create!(:slug => "revert_return_to_test") + node.lock_for_editing!(user) + node.autosave!({:title => "v1"}, user) + node.save_draft!(user) + node.publish_draft! + node.lock_for_editing!(user) + node.autosave!({:title => "v2"}, user) + node.save_draft!(user) + node.lock_for_editing!(user) + node.autosave!({:title => "v3"}, user) + # state D: head, draft, and autosave all present, locked by aaron + + login_as :aaron + put :revert, params: { :id => node.id, :return_to => node_path(node) } + assert_redirected_to node_path(node) + node.reload + assert node.draft.present? + assert node.autosave.blank? + end + + test "reverting from nodes#edit without return_to still lands back in the editor when a draft remains" do + user = User.find_by_login("aaron") + node = Node.root.children.create!(:slug => "revert_default_test") + node.lock_for_editing!(user) + node.autosave!({:title => "v1"}, user) + node.save_draft!(user) + node.publish_draft! + node.lock_for_editing!(user) + node.autosave!({:title => "v2"}, user) + node.save_draft!(user) + node.lock_for_editing!(user) + node.autosave!({:title => "v3"}, user) + + login_as :aaron + put :revert, params: { :id => node.id } + assert_redirected_to edit_node_path(node) + end end -- cgit v1.3 From 48eed5f35ea798c91f1e0a77c13e751145acfc48 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 18:10:57 +0200 Subject: Add the missing assertions two tests were silently running without Both ran real code and checked nothing -- Minitest's "missing assertions" warning has been firing on every run this session. test_find_or_create_draft_if_draft_exists_and_is_owned_by_user called the method twice but never checked what came back; now confirms the second call returns the same draft rather than creating a new one, and leaves the lock and page count untouched. test_destroy_a_published_node destroyed a node and loaded the admin index but never checked the response, unlike its two neighbors covering the same destroy path (dangling pages, orphaned occurrences). Added the assert_response :success its own shape already implied. No behavior changed -- both were passing before for the same reason they're passing now, they just weren't proving anything. --- test/controllers/nodes_controller_test.rb | 1 + test/models/node_test.rb | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index ce3419c..37091d5 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -355,6 +355,7 @@ class NodesControllerTest < ActionController::TestCase login_as :quentin get :index + assert_response :success end test "no dangling pages remain after node removal" do diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 9e71dec..280614e 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -111,15 +111,19 @@ class NodeTest < ActiveSupport::TestCase node.publish_draft! assert_not_nil node.find_or_create_draft( @user1 ) end - + def test_find_or_create_draft_if_draft_exists_and_is_owned_by_user node = Node.root.children.create :slug => "xyz" node.publish_draft! - - node.find_or_create_draft @user1 - node.find_or_create_draft @user1 + + first_call = node.find_or_create_draft @user1 + second_call = node.find_or_create_draft @user1 + + assert_equal first_call, second_call + assert_equal 2, node.pages.count + assert_equal @user1, node.lock_owner end - + def test_exception_if_draft_exists_but_locked_by_another_user node = Node.root.children.create :slug => "xyz" node.publish_draft! -- cgit v1.3 From 15aff3eff51809d1ce21caab406a0ef8b13624b6 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 11 Jul 2026 01:33:07 +0200 Subject: Do not offer to destroy the only draft of a never-published node --- app/views/nodes/show.html.erb | 2 +- test/controllers/nodes_controller_test.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 5756649..07cb5d1 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -49,7 +49,7 @@ form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> <% end %> - <% if @node.draft || @node.autosave %> + <% if @node.autosave || (@node.draft && @node.head) %>
<%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 37091d5..b563d4d 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -463,4 +463,12 @@ class NodesControllerTest < ActionController::TestCase put :revert, params: { :id => node.id } assert_redirected_to edit_node_path(node) end + + test "nodes#show does not offer to destroy the only draft of a never-published node" do + node = Node.root.children.create!(:slug => "draft_only_test") + login_as :quentin + get :show, params: { :id => node.id } + assert_response :success + assert_select "form.destructive", :count => 0 + end end -- cgit v1.3 From 92c394b43a0603743b914c6298aab986805ca990 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 11 Jul 2026 23:43:02 +0200 Subject: 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. --- app/controllers/nodes_controller.rb | 45 +++++++++++++ app/models/node.rb | 22 +++++++ app/views/nodes/_node_list.html.erb | 39 +++++++++++ app/views/nodes/chapters.html.erb | 9 +++ app/views/nodes/drafts.html.erb | 3 + app/views/nodes/mine.html.erb | 3 + app/views/nodes/recent.html.erb | 3 + app/views/nodes/tags.html.erb | 3 + config/routes.rb | 5 ++ public/stylesheets/admin.css | 15 +++++ test/controllers/nodes_controller_test.rb | 104 ++++++++++++++++++++++++++++++ test/models/node_test.rb | 26 ++++++++ 12 files changed, 277 insertions(+) create mode 100644 app/views/nodes/_node_list.html.erb create mode 100644 app/views/nodes/chapters.html.erb create mode 100644 app/views/nodes/drafts.html.erb create mode 100644 app/views/nodes/mine.html.erb create mode 100644 app/views/nodes/recent.html.erb create mode 100644 app/views/nodes/tags.html.erb (limited to 'test/controllers/nodes_controller_test.rb') 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 render plain: slug_for(params[:title]) end + # Filter functions for admin views + def drafts + base = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") + @nodes = index_matching(base) + end + + def recent + base = Node.where( + "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", + Time.now, Time.now - 14.days + ) + @nodes = index_matching(base) + end + + def mine + base = Node.joins(:pages) + .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) + .distinct + @nodes = index_matching(base) + end + + def chapters + @kind_keys = Array(params[:kinds]) & %w[erfa chaostreff] + @kind_keys = %w[erfa chaostreff] if @kind_keys.empty? + tags = @kind_keys.flat_map { |key| CccConventions::NODE_KINDS[key][:tags] } + @nodes = nodes_matching_tags(tags) + end + + def tags + tags = params[:tags].to_s.split(',').map(&:strip).reject(&:blank?) + @nodes = nodes_matching_tags(tags) + end + private def slug_for(title) @@ -214,4 +247,16 @@ class NodesController < ApplicationController config && config[:parent] ? config[:parent].call.id : nil end end + + def nodes_matching_tags(tags) + matching_pages = Page.tagged_with(tags, any: true).reselect(:id) + base = Node.where(head_id: matching_pages).or(Node.where(draft_id: matching_pages)) + index_matching(base) + end + + def index_matching(base_scope) + scope = base_scope.includes(:head, :draft) + scope = scope.merge(Node.editor_search(params[:q])) if params[:q].present? + scope.order("nodes.updated_at desc").paginate(page: params[:page], per_page: 25) + end 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 .distinct end + # This one is for admin-only views, where finding a draft is the point. + # Substring match on whichever of head/draft is present. + def self.editor_search(term) + words = term.to_s.split(/\s+/).reject(&:blank?) + return none if words.empty? + + conditions = [] + binds = {} + + words.each_with_index do |word, i| + key = "term#{i}" + binds[key.to_sym] = "%#{sanitize_sql_like(word)}%" + conditions << "(head_translations.title ILIKE :#{key} OR head_translations.abstract ILIKE :#{key} " \ + "OR draft_translations.title ILIKE :#{key} OR draft_translations.abstract ILIKE :#{key})" + end + + joins("LEFT JOIN page_translations head_translations ON head_translations.page_id = nodes.head_id") + .joins("LEFT JOIN page_translations draft_translations ON draft_translations.page_id = nodes.draft_id") + .where(conditions.join(" AND "), binds) + .distinct + end + protected def lock_for! current_user 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 @@ +<%= form_tag url_for(controller: params[:controller], action: params[:action]), method: :get, class: "node_search_form" do %> + <% Array(params[:kinds]).each do |kind| %> + <%= hidden_field_tag "kinds[]", kind %> + <% end %> + <%= hidden_field_tag :tags, params[:tags] if params[:tags].present? %> + <%= text_field_tag :q, params[:q], placeholder: "Search title, abstract, body…" %> + <%= submit_tag "Search", class: "action_button" %> + <% if params[:q].present? || params[:kinds].present? || params[:tags].present? %> + <%= link_to "Reset", url_for(controller: params[:controller], action: params[:action]) %> + <% end %> +<% end %> + +<%= will_paginate @nodes %> + + + + + + + + + <% @nodes.each do |node| %> + "> + + + + + + + <% end %> +
IDTitleActionsLocked byRev.
<%= node.id %> +

<%= link_to title_for_node(node), node_path(node) %>

+

<%= link_to_path(node.unique_name, node.unique_name) %>

+
+ <%= link_to 'show', node_path(node) %> + <%= link_to 'edit', edit_node_path(node) %> + <%= link_to 'revisions', node_revisions_path(node) %> + <%= node.lock_owner.login if node.lock_owner %><%= node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY") %>
+<%= 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 @@ +

Chapters

+ +<%= form_tag chapters_nodes_path, method: :get do %> + + + <%= submit_tag "Filter", class: "action_button" %> +<% end %> + +<%= 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 @@ +

Nodes with drafts or autosaves

+ +<%= 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 @@ +

My work

+ +<%= 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 @@ +

Recently changed

+ +<%= 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 @@ +

Nodes tagged: <%= params[:tags] %>

+ +<%= 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 resources :nodes do collection do + get 'tags/:tags', action: :tags, as: :tags, constraints: { tags: /[^\/]+/ } get :parameterize_preview + get :drafts + get :recent + get :mine + get :chapters end 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"] { margin-bottom: 0; } +.node_search_form { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.node_search_form input[type=text] { + padding: 4px 12px; + box-sizing: border-box; + height: 2.25rem; + width: 22rem; + border-radius: 2px; +} + /* Layout only -- the at-rest visibility (wavy underline) for these links comes from the scoped rule in Base elements above. */ .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 assert_response :success assert_select "form.destructive", :count => 0 end + + test "drafts includes a never-published node with only a draft" do + node = Node.root.children.create!(:slug => "drafts_action_test") + login_as :quentin + get :drafts + assert_includes assigns(:nodes), node + end + + test "chapters filters by kind, matching head or draft, and shows both by default" do + erfa_node = Node.root.children.create!(:slug => "chapters_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + chaostreff_node = Node.root.children.create!(:slug => "chapters_chaostreff_test") + chaostreff_node.find_or_create_draft(@user1) + chaostreff_node.draft.tag_list = "chaostreff-detail" + chaostreff_node.draft.save! + chaostreff_node.publish_draft! + + login_as :quentin + + get :chapters, params: { :kinds => "erfa" } + assert_includes assigns(:nodes), erfa_node + assert_not_includes assigns(:nodes), chaostreff_node + + get :chapters + assert_includes assigns(:nodes), erfa_node + assert_includes assigns(:nodes), chaostreff_node + end + + test "recent combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :recent, params: { :q => "Zombies" } + assert_response :success + end + + test "drafts combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :drafts, params: { :q => "Zombies" } + assert_response :success + end + + test "mine combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :mine, params: { :q => "Zombies" } + assert_response :success + end + + test "chapters combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :chapters, params: { :q => "Zombies" } + assert_response :success + end + + test "tags path filters by an arbitrary raw tag, generalizing chapters" do + presse_node = Node.root.children.create!(:slug => "tags_path_presse_test") + presse_node.find_or_create_draft(@user1) + presse_node.draft.tag_list = "pressemitteilung" + presse_node.draft.save! + presse_node.publish_draft! + + erfa_node = Node.root.children.create!(:slug => "tags_path_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + login_as :quentin + get :tags, params: { :tags => "pressemitteilung" } + + assert_includes assigns(:nodes), presse_node + assert_not_includes assigns(:nodes), erfa_node + + assert_select "h1", "Nodes tagged: pressemitteilung" + assert_select "h1", :text => "Chapters", :count => 0 + end + + test "tags path with multiple tags matches any of them, not all" do + erfa_node = Node.root.children.create!(:slug => "tags_path_multi_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + chaostreff_node = Node.root.children.create!(:slug => "tags_path_multi_chaostreff_test") + chaostreff_node.find_or_create_draft(@user1) + chaostreff_node.draft.tag_list = "chaostreff-detail" + chaostreff_node.draft.save! + chaostreff_node.publish_draft! + + login_as :quentin + get :tags, params: {:tags => "erfa-detail,chaostreff-detail" } + + assert_includes assigns(:nodes), erfa_node + assert_includes assigns(:nodes), chaostreff_node + end + + test "chapters renders the curated heading" do + login_as :quentin + get :chapters + assert_select "h1", "Chapters" + end 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 a.reload assert_equal Node.root.id, a.parent_id end + + test "editor_search matches a partial substring, not just a whole word" do + node = Node.root.children.create!(:slug => "editor_search_substring_test") + node.find_or_create_draft(@user1) + node.draft.update(:title => "Biometrics Conference") + node.publish_draft! + + assert_includes Node.editor_search("bio"), node + assert_includes Node.editor_search("Conf"), node + end + + test "editor_search returns an empty relation for a blank term, not every node" do + assert_equal 0, Node.editor_search("").count + assert_equal 0, Node.editor_search(nil).count + assert_equal 0, Node.editor_search(" ").count + end + + test "editor_search requires every word to match, but each word can match a different field" do + node = Node.root.children.create!(:slug => "editor_search_multiword_test") + node.find_or_create_draft(@user1) + node.draft.update(:title => "Backspace e.V. Bamberg", :abstract => "Spiegelgraben 41, 96052 Bamberg") + node.publish_draft! + + assert_includes Node.editor_search("Backspace Spiegelgraben"), node + assert_equal 0, Node.editor_search("Backspace Nonexistentstreet").count + end end -- cgit v1.3 From 45bf65d04d046c0ea4a1150096b2a9b846d6c0b8 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 02:15:44 +0200 Subject: Give the sitemap its own view, with collapse and descendant counts Extracted from admin#index's inline table into NodesController#sitemap. Nested
/ per branch, one linear pass over the existing flat [node, level] list (no added queries) -- each node's own descendant count computed the same way, via a small stack rather than re-walking the tree per node. Branches under updates/, club/erfas, club/chaostreffs, and disclosure start collapsed by default (CccConventions::SITEMAP_COLLAPSED_PATHS); any branch currently collapsed, whether by that default or because someone just closed it, is highlighted via a plain :not([open]) selector -- no state tracked outside the DOM itself. Dropped the update?-post exclusion this view used to rely on -- no longer needed now that updates/ collapses instead of being filtered out, so its real children (previously silently absent) now show up correctly. admin#index's own, separate @sitemap query is unchanged; that view has no collapse mechanism to compensate and wasn't part of this. --- app/controllers/nodes_controller.rb | 23 ++++++++++++++ app/helpers/nodes_helper.rb | 5 ++- app/views/nodes/sitemap.html.erb | 32 +++++++++++++++++++ config/routes.rb | 1 + lib/ccc_conventions.rb | 1 + public/stylesheets/admin.css | 40 ++++++++++++++++++++++++ test/controllers/nodes_controller_test.rb | 52 +++++++++++++++++++++++++++++++ test/models/helpers/nodes_helper_test.rb | 11 +++++++ 8 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 app/views/nodes/sitemap.html.erb (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index ede91ad..772bf4b 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -216,6 +216,11 @@ class NodesController < ApplicationController @nodes = nodes_matching_tags(tags) end + def sitemap + @sitemap = Node.root.self_and_descendants_ordered_with_level + @sitemap_descendant_counts = descendant_counts_for(@sitemap) + end + private def slug_for(title) @@ -248,6 +253,24 @@ class NodesController < ApplicationController end end + def descendant_counts_for(ordered_with_level) + counts = Hash.new(0) + stack = [] # [node, level, index] + ordered_with_level.each_with_index do |(node, level), index| + while stack.any? && stack.last[1] >= level + ancestor_node, _ancestor_level, ancestor_index = stack.pop + counts[ancestor_node.id] = index - ancestor_index - 1 + end + stack << [node, level, index] + end + total = ordered_with_level.length + while stack.any? + ancestor_node, _ancestor_level, ancestor_index = stack.pop + counts[ancestor_node.id] = total - ancestor_index - 1 + end + counts + end + def nodes_matching_tags(tags) matching_pages = Page.tagged_with(tags, any: true).reselect(:id) base = Node.where(head_id: matching_pages).or(Node.where(draft_id: matching_pages)) diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index a89f879..1268b63 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -12,7 +12,6 @@ module NodesHelper end end - def truncated_title_for_node node if (title = title_for_node node) && title.size > 20 "#{truncate(title, 40)}" @@ -63,4 +62,8 @@ module NodesHelper path = node.unique_path CccConventions::NODE_KINDS.select { |_, config| config[:parent_match]&.call(path) } end + + def sitemap_node_open?(node) + !CccConventions::SITEMAP_COLLAPSED_PATHS.include?(node.unique_name) + end end diff --git a/app/views/nodes/sitemap.html.erb b/app/views/nodes/sitemap.html.erb new file mode 100644 index 0000000..a799c17 --- /dev/null +++ b/app/views/nodes/sitemap.html.erb @@ -0,0 +1,32 @@ +

Sitemap

+ +
+<% + open_details = [] # levels with a currently-open
+%> +<% @sitemap.each_with_index do |(node, level), index| %> + <% while open_details.any? && open_details.last >= level %> +
+ <% open_details.pop %> + <% end %> + +
+

<%= link_to title_for_node(node), node_path(node) %>

+ <%= link_to_path("#{node.unique_name} ↗", node.unique_name) %> +

+ <%= link_to 'Show', node_path(node) %> + <%= link_to 'Create Child', new_node_path(:parent_id => node.id) %> +

+
+ + <% next_level = @sitemap[index + 1]&.last %> + <% if next_level && next_level > level %> + > + + <%= pluralize(@sitemap_descendant_counts[node.id], 'descendant', 'descendants') %> + + <% open_details.push(level) %> + <% end %> +<% end %> +<% open_details.length.times { %>
<% } %> +
diff --git a/config/routes.rb b/config/routes.rb index 2c165d2..bb34bd8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,6 +43,7 @@ Cccms::Application.routes.draw do get :recent get :mine get :chapters + get :sitemap end member do diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb index b420452..352dd3c 100644 --- a/lib/ccc_conventions.rb +++ b/lib/ccc_conventions.rb @@ -1,6 +1,7 @@ module CccConventions ERFA_PARENT_NAME = "club/erfas" CHAOSTREFF_PARENT_NAME = "club/chaostreffs" + SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze NODE_KINDS = { "top_level" => { diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index aa8b288..5c1e489 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -25,6 +25,7 @@ a:hover { body content, node listing tables, the child-creation shortcuts on nodes#show, and the dashboard draft list's Show/Revisions links. */ #page_editor a, +#sitemap a, table.node_table a, table.assets_table a, table.events_table a, @@ -104,6 +105,11 @@ input[type=radio] { width: 100%; box-sizing: border-box; } + + #sitemap details details { + margin-left: 0.75rem; + padding-left: 0.5rem; + } } #metadata, @@ -773,6 +779,40 @@ form.button_to button[type="submit"] { white-space: nowrap; } +.sitemap_node { + padding-bottom: 0.5rem; + margin-bottom: 0.5rem; + border-bottom: 1px solid #ececec; +} + +.sitemap_node h4 { + margin: 0; +} + +.sitemap_node .field_hint { + display: block; + margin: 2px 0 0; +} + +.sitemap_node p { + margin: 2px 0 0; +} + +#sitemap details details { + margin-left: 1.5rem; + border-left: 1px solid #e8e8e8; + padding-left: 0.75rem; +} + +#sitemap summary { + padding: 4px 0; +} + +#sitemap details:not([open]) > summary { + color: #ff9600; + font-weight: bold; +} + /* ============================================================ Page editor / metadata forms ============================================================ */ diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 05cb195..b43d2de 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -575,4 +575,56 @@ class NodesControllerTest < ActionController::TestCase get :chapters assert_select "h1", "Chapters" end + + test "sitemap collapses configured paths but leaves others open" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + erfas.children.create!(:slug => "one_chapter") + other = Node.root.children.create!(:slug => "sitemap_controller_open_test") + other.children.create!(:slug => "sitemap_controller_open_child") + + login_as :quentin + get :sitemap + assert_response :success + + doc = Nokogiri::HTML::DocumentFragment.parse(response.body) + + erfas_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(erfas.unique_name) } + other_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(other.unique_name) } + + erfas_details = erfas_node_div.next_element + other_details = other_node_div.next_element + + assert_equal 'details', erfas_details.name + assert_equal 'details', other_details.name + assert_not erfas_details.key?('open') + assert other_details.key?('open') + end + + test "sitemap shows how many descendants a collapsed branch is hiding" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + erfas.children.create!(:slug => "one_chapter") + erfas.children.create!(:slug => "another_chapter") + + login_as :quentin + get :sitemap + assert_response :success + + doc = Nokogiri::HTML::DocumentFragment.parse(response.body) + erfas_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(erfas.unique_name) } + erfas_details = erfas_node_div.next_element + + assert_equal 'details', erfas_details.name + assert_match "2 descendants", erfas_details.at_css('summary').text + end + + test "sitemap shows Show and Create Child, not Revisions" do + node = Node.root.children.create!(:slug => "sitemap_actions_test") + login_as :quentin + get :sitemap + assert_select ".sitemap_node_actions", :text => /Show/ + assert_select ".sitemap_node_actions", :text => /Create Child/ + assert_select ".sitemap_node_actions", :text => /Revisions/, :count => 0 + end end diff --git a/test/models/helpers/nodes_helper_test.rb b/test/models/helpers/nodes_helper_test.rb index 5d91a88..5ab924f 100644 --- a/test/models/helpers/nodes_helper_test.rb +++ b/test/models/helpers/nodes_helper_test.rb @@ -22,4 +22,15 @@ class NodesHelperTest < ActionView::TestCase page = FakePage.new([]) assert_nil default_event_tag_list(page) end + + test "sitemap_node_open? is false for a configured collapsed path" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + assert_equal false, sitemap_node_open?(erfas) + end + + test "sitemap_node_open? is true for anything not configured as collapsed" do + node = Node.root.children.create!(:slug => "sitemap_open_test") + assert_equal true, sitemap_node_open?(node) + end end -- cgit v1.3 From b271648f89cba7cafafa1b73b1658b1c1bc0e4b0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 23:42:43 +0200 Subject: Rebuild the admin dashboard: icon nav, search, signposts, widgets Replaces the old admin#index wizard -- accreted over years, never designed as a whole -- with the dashboard settled on this session: a three-icon nav (dashboard/search/log out, no locale selector), a nodes-first search bar, four task signposts, and two symmetric widgets (drafts/autosaves, recent changes) with a quiet housekeeping row beneath them. Node.recently_changed now filters and orders by the head page's own updated_at instead of the node's blanket timestamp, so a lock/unlock cycle with no actual publish no longer surfaces here, and the original publisher is no longer misattributed to someone else's housekeeping action. This also restores the "published" qualifier on each entry, which the query previously couldn't guarantee was true. @mynodes and its dedicated "My Work" table are retired along with the old wizard -- "Continue my work" is a link to the existing, already- correct NodesController#mine instead of a second, duplicate query. Its one dedicated test (dedup across multiple revisions by the same user) is ported to nodes_controller_test.rb, since mine already carries the same .distinct protection the old query did; it just had no test of its own until now. --- app/controllers/admin_controller.rb | 21 +-- app/helpers/nodes_helper.rb | 12 ++ app/models/node.rb | 6 +- app/views/admin/_menu.html.erb | 14 +- app/views/admin/index.html.erb | 210 ++++++++++-------------------- test/controllers/admin_controller_test.rb | 20 --- test/controllers/nodes_controller_test.rb | 20 +++ test/models/node_test.rb | 24 ++++ 8 files changed, 134 insertions(+), 193 deletions(-) (limited to 'test/controllers/nodes_controller_test.rb') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 8445997..37fd78b 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -5,25 +5,8 @@ class AdminController < ApplicationController before_action :login_required def index - @drafts = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") - .limit(50).order("updated_at desc") - - @drafts_count = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").count - - @recent_changes = Node.where( - "updated_at < ? AND updated_at > ? AND parent_id IS NOT NULL", - Time.now, Time.now - 14.days - ).limit(50).order("updated_at desc") - - ordered_with_level = Node.root.self_and_descendants_ordered_with_level - @sitemap_depth = {} - ordered_with_level.each { |node, level| @sitemap_depth[node.id] = level } - @sitemap = ordered_with_level.map(&:first).reject(&:update?) - - @mynodes = Node.joins(:pages) - .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) - .order("updated_at desc") - .distinct.first(50) + @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5) + @recent_changes = Node.recently_changed.limit(5) end def conventions diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 1268b63..5884c8c 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -28,6 +28,18 @@ module NodesHelper User.all.map {|u| [u.login, u.id]} end + def node_last_editor(node) + editor = node.draft&.editor || node.head&.editor + return nil unless editor + editor == current_user ? t("editor_self") : editor.login + end + + def node_head_editor(node) + editor = node.head&.editor + return nil unless editor + editor == current_user ? t("publisher_self") : editor.login + end + DEFAULT_EVENT_TAG_BY_PAGE_TAG = { 'erfa-detail' => 'open-day', 'chaostreff-detail' => 'open-day' diff --git a/app/models/node.rb b/app/models/node.rb index aa2f7f3..1d0a089 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -377,10 +377,10 @@ class Node < ApplicationRecord end def self.recently_changed - where( - "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", + includes(:head).where( + "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", Time.now, Time.now - 14.days - ) + ).order("pages.updated_at desc").references(:head) end protected diff --git a/app/views/admin/_menu.html.erb b/app/views/admin/_menu.html.erb index 4302987..970429d 100644 --- a/app/views/admin/_menu.html.erb +++ b/app/views/admin/_menu.html.erb @@ -1,9 +1,5 @@ -<%= language_selector %> -<%= button_to 'Logout', logout_path, method: :delete %> -<%= link_to 'Overview', admin_path %> -search -<%= link_to 'Nodes', nodes_path, selected?('nodes') %> -<%= link_to 'Assets', assets_path, selected?('assets') %> -<%= link_to 'Events', events_path, selected?('events') %> -<%= link_to 'User', users_path, selected?('users') %> -<%= link_to 'Navigation', menu_items_path, selected?('menu_items') %> +<%= link_to icon("home", library: "tabler", "aria-hidden": true), admin_path, "aria-label": "Dashboard" %> +<%= icon("search", library: "tabler", "aria-hidden": true) %> +<%= button_to logout_path, method: :delete, aria: { label: "Log out" } do %> + <%= icon("logout", library: "tabler", "aria-hidden": true) %> +<% end %> diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index c67ccb3..bdb555e 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -1,155 +1,81 @@ -
-

Quick links

-
<%= link_to 'Create post or page', new_node_path, :only_path => false %>
- - -
<%= link_to("Upload a new asset", new_asset_path, :only_path => false) %>
+

cccms dashboard

+ -

- recent changes - my work - current drafts (<%= @drafts_count %>) - site map -

+
+ <%= link_to new_node_path, class: "action_button" do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create post + <% end %> + <%= link_to chapters_nodes_path, class: "action_button" do %> + <%= icon("map-pin", library: "tabler", "aria-hidden": true) %> Find a chapter + <% end %> + <%= link_to new_asset_path, class: "action_button" do %> + <%= icon("upload", library: "tabler", "aria-hidden": true) %> Upload asset + <% end %> + <%= link_to sitemap_nodes_path, class: "action_button" do %> + <%= icon("list-tree", library: "tabler", "aria-hidden": true) %> Add a page in the page tree + <% end %> +
-
- -

Recent Changes

+
+
+

Drafts and autosaves

+
    + <% @drafts.each do |node| %> +
  • +
    + <%= link_to title_for_node(node), node_path(node) %> + <%= link_to_path("#{node.unique_name} ↗", node.unique_name) %> +
    + + <% if (editor = node_last_editor(node)) %><%= editor %>, <% end %><%= relative_time_phrase(node.updated_at) %> + +
  • + <% end %> +
+ <%= link_to "See all drafts →", drafts_nodes_path %> +
- - - - - - - - +
+

Recent changes

+
    <% @recent_changes.each do |node| %> -
"> - - - - - - +
  • +
    + <%= link_to title_for_node(node), node_path(node) %> + <%= link_to_path("#{node.unique_name} ↗", node.unique_name) %> +
    + + <%= (editor = node_head_editor(node)) ? t("published_by", editor: editor) : t("published") %>, <%= relative_time_phrase(node.head.updated_at) %> + +
  • <% end %> -
    IDTitleActionsLocked byRev.
    <%= node.id %> -

    <%= link_to title_for_node(node), node_path(node) %>

    -

    <%= link_to_path(node.unique_name, node.unique_name) %>

    -
    - <%= link_to 'show', node_path(node) %> - <%= link_to 'Revisions', node_revisions_path(node) %> - - <%= node.lock_owner.login if node.lock_owner %> - - <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> -
    + + <%= link_to "See all recent changes →", recent_nodes_path %> +
    -
    - -

    My Work

    - - - - - - - - - - <% @mynodes.each do |node| %> - "> - - - - - - +
    +

    Housekeeping

    +
    + <%= link_to nodes_path, class: "action_button" do %> + <%= icon("list", library: "tabler", "aria-hidden": true) %> Nodes <% end %> -
    IDTitleActionsLocked byRev.
    <%= node.id %> -

    <%= link_to title_for_node(node), node_path(node) %>

    -

    <%= link_to_path(node.unique_name, node.unique_name) %>

    -
    - <%= link_to 'show', node_path(node) %> - <%= link_to 'Revisions', node_revisions_path(node) %> - - <%= node.lock_owner.login if node.lock_owner %> - - <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> -
    -
    - -
    - -

    Current Drafts & Autosaves (<%= @drafts_count %>)

    - - - - - - - - - - - <% @drafts.each do |node| %> - "> - - - - - - - + <%= link_to events_path, class: "action_button" do %> + <%= icon("calendar", library: "tabler", "aria-hidden": true) %> Events <% end %> -
    IDTitleActionsLocked byRev.Autosave
    <%= node.id %> -

    <%= link_to title_for_node(node), node_path(node) %>

    -

    <%= link_to_path(node.unique_name, node.unique_name) %>

    -
    - <%= link_to 'show', node_path(node) %> - <%= link_to 'Revisions', node_revisions_path(node) %> - <% if pair = node.available_layer_pairs.last %> - <%= link_to 'diff', diff_node_revisions_path(node, start_revision: pair.first, end_revision: pair.last) %> - <% end %> - - <%= node.lock_owner.login if node.lock_owner %> - - <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> - - <%= node.autosave ? "unsaved changes" : "" %> -
    -
    - -
    - -

    Sitemap

    - - - - - - - - - <% @sitemap.each do |node| %> - <% if !node.nil? %> - "> - - - - - + <%= link_to assets_path, class: "action_button" do %> + <%= icon("folder", library: "tabler", "aria-hidden": true) %> Assets + <% end %> + <%= link_to users_path, class: "action_button" do %> + <%= icon("users", library: "tabler", "aria-hidden": true) %> Users <% end %> + <%= link_to menu_items_path, class: "action_button" do %> + <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation <% end %> -
    IDTitleActionsLocked by
    <%= node.id %> -

    <%= link_to title_for_node(node), node_path(node) %>

    -

    <%= link_to_path(node.unique_name, node.unique_name) %>

    -
    - <%= link_to 'create subpage', new_node_path(:parent_id => node.id) %> - <%= link_to 'Revisions', node_revisions_path(node) %> - - <%= node.lock_owner.login if node.lock_owner %> -
    +
    diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 9beaf58..00a51e2 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -15,26 +15,6 @@ class AdminControllerTest < ActionController::TestCase assert_includes assigns(:drafts), node end - test "my work list shows each matching node only once, even with several revisions by the same user" do - login_as :quentin - user = User.find_by_login("quentin") - node = Node.root.children.create!(:slug => "dedup_test") - node.lock_for_editing!(user) - node.autosave!({:title => "v1"}, user) - node.save_draft!(user) - node.publish_draft! - node.lock_for_editing!(user) - node.autosave!({:title => "v2"}, user) - node.save_draft!(user) - node.publish_draft! - # three pages now exist on this node, all touched by quentin -- - # without DISTINCT, the join would return this node three times - - get :index - matches = assigns(:mynodes).select { |n| n.id == node.id } - assert_equal 1, matches.length - end - test "dashboard_search returns matching tags and nodes grouped separately" do node = Node.root.children.create!(:slug => "dashboard_search_test") node.find_or_create_draft(User.find_by_login("aaron")) diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index b43d2de..81e3f45 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -521,6 +521,26 @@ class NodesControllerTest < ActionController::TestCase assert_response :success end + test "mine shows each matching node only once, even with several revisions by the same user" do + login_as :quentin + user = User.find_by_login("quentin") + node = Node.root.children.create!(:slug => "dedup_test") + node.lock_for_editing!(user) + node.autosave!({:title => "v1"}, user) + node.save_draft!(user) + node.publish_draft! + node.lock_for_editing!(user) + node.autosave!({:title => "v2"}, user) + node.save_draft!(user) + node.publish_draft! + # three pages now exist on this node, all touched by quentin -- + # without DISTINCT, the join would return this node three times + + get :mine + matches = assigns(:nodes).select { |n| n.id == node.id } + assert_equal 1, matches.length + end + test "chapters combined with a search term does not raise an ambiguous column error" do login_as :quentin get :chapters, params: { :q => "Zombies" } diff --git a/test/models/node_test.rb b/test/models/node_test.rb index de540f8..0bce222 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -561,4 +561,28 @@ class NodeTest < ActiveSupport::TestCase result = Node.drafts_and_autosaves(current_user_id: @user1.id).to_a assert result.index(mine) < result.index(someone_elses_newer) end + + test "recently_changed includes a node whose head was recently published" do + node = Node.root.children.create!(:slug => "recent_changed_published") + node.find_or_create_draft(@user1) + node.autosave!({:title => "v1"}, @user1) + node.save_draft!(@user1) + node.publish_draft! + + assert_includes Node.recently_changed, node + end + + test "recently_changed excludes a node only touched by locking or unlocking after an old publish" do + node = Node.root.children.create!(:slug => "recent_changed_lock_only") + node.find_or_create_draft(@user1) + node.autosave!({:title => "v1"}, @user1) + node.save_draft!(@user1) + node.publish_draft! + node.head.update_column(:updated_at, 20.days.ago) + + node.lock_for_editing!(@user1) + node.unlock! + + assert_not_includes Node.recently_changed, node + end end -- cgit v1.3