From 51629c5c42270a346885057a441095c964101cc1 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 30 Jun 2026 03:55:42 +0200 Subject: Fix events CRUD for standalone events and add events to admin menu - event_params now permits title, description, is_primary - event_information helper lists all node.events, not just the first - Occurrence.generate handles nil node (standalone events) - Page.aggregate order_by title uses correlated subquery to avoid GROUP BY conflict with tag-filter path; order_direction whitelisted to ASC/DESC to prevent SQL injection - Events link added to admin menu bar - events/index shows title, is_primary; drops latitude/longitude columns --- app/controllers/events_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/controllers') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index f50da3e..3a60cf9 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -94,6 +94,6 @@ class EventsController < ApplicationController private def event_params - params.require(:event).permit(:start_time, :end_time, :rrule, :custom_rrule, :allday, :url, :latitude, :longitude, :node_id, :location) + params.require(:event).permit(:title, :description, :is_primary, :start_time, :end_time, :rrule, :custom_rrule, :allday, :url, :latitude, :longitude, :node_id, :location) end end -- cgit v1.3 From b6416e86c9c58b8e886c14de55b01aeb863b8676 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 00:12:02 +0200 Subject: Clean up events views: return_to, subnav, remove custom_rrule - events_controller: wire return_to through show and new actions; create respects return_to with fallback to node/event path; new pre-populates node_id and tag_list from params - events/edit: remove custom_rrule checkbox; node link removed from subnav (use show for node navigation); destroy button added - events/new: remove custom_rrule checkbox; add return_to hidden field and back link; tag_list field added - events/show: fix back link via safe_return_to; add node link to subnav; add destroy button; remove custom_rrule display; show humanized rrule below raw string - events/index: destructive class on destroy button; node_id column replaced with node link; show/edit links carry return_to - nodes/show: add show/edit links to event entries with return_to --- app/controllers/events_controller.rb | 10 +++++++--- app/views/events/edit.html.erb | 9 +++------ app/views/events/index.html.erb | 10 ++++------ app/views/events/new.html.erb | 7 ++++--- app/views/events/show.html.erb | 16 +++++++++++----- app/views/nodes/show.html.erb | 6 +++++- 6 files changed, 34 insertions(+), 24 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 3a60cf9..9bed5dd 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -21,6 +21,7 @@ class EventsController < ApplicationController # GET /events/1.xml def show @event = Event.find(params[:id]) + @return_to = params[:return_to] || events_path respond_to do |format| format.html # show.html.erb @@ -31,7 +32,10 @@ class EventsController < ApplicationController # GET /events/new # GET /events/new.xml def new - @event = Event.new(:node_id => params[:node_id]) + @event = Event.new( + node_id: params[:node_id], + tag_list: params[:tag_list] + ) respond_to do |format| format.html # new.html.erb @@ -53,7 +57,7 @@ class EventsController < ApplicationController respond_to do |format| if @event.save flash[:notice] = 'Event was successfully created.' - format.html { redirect_to(@event.node ? edit_node_path(@event.node) : edit_event_path(@event)) } + format.html { redirect_to(safe_return_to(params[:return_to] || (@event.node ? edit_node_path(@event.node) : edit_event_path(@event)))) } format.xml { render :xml => @event, :status => :created, :location => @event } else format.html { render :action => "new" } @@ -94,6 +98,6 @@ class EventsController < ApplicationController private def event_params - params.require(:event).permit(:title, :description, :is_primary, :start_time, :end_time, :rrule, :custom_rrule, :allday, :url, :latitude, :longitude, :node_id, :location) + params.require(:event).permit(:title, :description, :start_time, :end_time, :rrule, :allday, :url, :latitude, :longitude, :node_id, :location, :tag_list) end end diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 17457df..5aee501 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -1,9 +1,6 @@ <% content_for :subnavigation do %> <%= link_to 'back', safe_return_to(params[:return_to] || events_path) %> - <% if @event.node %> - <%= link_to 'node', edit_node_path(@event.node) %> - <% end %> - <%= link_to 'show', @event %> + <%= button_to 'destroy', event_path(@event), method: :delete, form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } %> <% end %>

Editing event

@@ -25,8 +22,8 @@ <%= f.text_field :rrule %>

- <%= f.label :custom_rrule %>
- <%= f.check_box :custom_rrule %> + <%= f.label :tag_list, "Tags" %>
+ <%= f.text_field :tag_list %>

<%= f.label :allday %>
diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 064fa86..d0458d7 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -3,7 +3,6 @@ - @@ -16,17 +15,16 @@ <% @events.each do |event| %> - - - - - + + + + <% end %>
TitleIs primary Start time End time Rrule
<%=h event.display_title %><%=h event.is_primary %> <%=h event.start_time %> <%=h event.end_time %> <%=h event.rrule %> <%=h event.custom_rrule %> <%=h event.allday %> <%=h event.url %><%=h event.node_id %><%= link_to 'Show', event %><%= link_to 'Edit', edit_event_path(event) %><%= button_to 'Destroy', event, method: :delete, form: { data: { confirm: 'Are you sure?' } } %><%= event.node ? link_to(event.node_id, node_path(event.node)) : '' %><%= link_to 'show', event %><%= link_to 'edit', edit_event_path(event) %><%= button_to 'destroy', event, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } %>
diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb index cd892c5..4e1ef53 100644 --- a/app/views/events/new.html.erb +++ b/app/views/events/new.html.erb @@ -16,8 +16,8 @@ <%= f.text_field :rrule %>

- <%= f.label :custom_rrule %>
- <%= f.check_box :custom_rrule %> + <%= f.label :tag_list, "Tags" %>
+ <%= f.text_field :tag_list %>

<%= f.label :allday %>
@@ -37,10 +37,11 @@

<%= f.hidden_field :node_id %> + <%= hidden_field_tag :return_to, params[:return_to] %>

<%= f.submit 'Create' %>

<% end %> -<%= link_to 'Back', events_path %> +<%= link_to 'Back', safe_return_to(params[:return_to] || events_path) %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index ba14a7d..e206bc4 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -1,6 +1,9 @@ <% content_for :subnavigation do %> - <%= link_to 'back', edit_node_path(@event.node) %> - <%= link_to 'edit', edit_event_path(@event) %> + <% if @event.node %> + <%= link_to 'node', node_path(@event.node) %> + <% end %> + <%= link_to 'edit', edit_event_path(@event, return_to: request.path) %> + <%= button_to 'destroy', event_path(@event), method: :delete, form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } %> <% end %>

Event for node <%= @event.node.unique_name %>

@@ -18,11 +21,14 @@

Rrule: <%=h @event.rrule %> + <% if (human = @event.humanize_rrule(I18n.locale)) %> +
( <%= human %> ) + <% end %>

- Custom rrule: - <%=h @event.custom_rrule %> + Tags: + <%=h @event.tag_list %>

@@ -43,4 +49,4 @@

Longitude: <%=h @event.longitude %> -

\ No newline at end of file +

diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 7223219..2ce3853 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -48,7 +48,11 @@ -- cgit v1.3 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. --- app/controllers/events_controller.rb | 4 + app/helpers/nodes_helper.rb | 14 +++ app/views/nodes/show.html.erb | 4 +- test/controllers/events_controller_test.rb | 167 ++++++++++++++++++++------- test/controllers/nodes_controller_test.rb | 33 ++++++ test/models/concerns/rrule_humanizer_test.rb | 15 +++ test/models/helpers/content_helper_test.rb | 4 + test/models/helpers/nodes_helper_test.rb | 21 ++++ 8 files changed, 219 insertions(+), 43 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 9bed5dd..b98a38e 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -37,6 +37,10 @@ class EventsController < ApplicationController tag_list: params[:tag_list] ) + if params[:tag_list].present? && params[:auto_tag_source].present? + flash.now[:notice] = "Tag '#{params[:tag_list]}' was pre-filled because this page is tagged '#{params[:auto_tag_source]}'. You can remove it below." + end + respond_to do |format| format.html # new.html.erb format.xml { render :xml => @event } diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 329bcc5..093bfc6 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -44,6 +44,20 @@ module NodesHelper ]) end + DEFAULT_EVENT_TAG_BY_PAGE_TAG = { + 'erfa-detail' => 'open-day', + 'chaostreff-detail' => 'open-day' + }.freeze + + def default_event_tag_mapping(page) + page_tags = page.tag_list + DEFAULT_EVENT_TAG_BY_PAGE_TAG.find { |page_tag, _| page_tags.include?(page_tag) } + end + + def default_event_tag_list(page) + default_event_tag_mapping(page)&.last + end + def event_schedule_text(event) if event.rrule.present? recurrence = event.humanize_rrule(I18n.locale) diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 2ce3853..c533a55 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -42,7 +42,6 @@ Tagged with: <%= @page.tag_list %> - <% if @node.events.any? %> Events @@ -55,9 +54,10 @@ <% end %> + <% mapping = default_event_tag_mapping(@page) %> + <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %> - <% end %> Title <%= sanitize( @page.title ) %> diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index 14e534e..9371ca7 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -1,45 +1,130 @@ require 'test_helper' class EventsControllerTest < ActionController::TestCase - # test "should get index" do - # get :index - # assert_response :success - # assert_not_nil assigns(:events) - # end - # - # test "should get new" do - # get :new - # assert_response :success - # end - # - # test "should create event" do - # assert_difference('Event.count') do - # post :create, params: { :event => { } } - # end - # - # assert_redirected_to event_path(assigns(:event)) - # end - # - # test "should show event" do - # get :show, params: { :id => events(:one).to_param } - # assert_response :success - # end - # - # test "should get edit" do - # get :edit, params: { :id => events(:one).to_param } - # assert_response :success - # end - # - # test "should update event" do - # put :update, params: { :id => events(:one).to_param, :event => { } } - # assert_redirected_to event_path(assigns(:event)) - # end - # - # test "should destroy event" do - # assert_difference('Event.count', -1) do - # delete :destroy, params: { :id => events(:one).to_param } - # end - # - # assert_redirected_to events_path - # end + + test "should get index" do + login_as :quentin + get :index + assert_response :success + assert_not_nil assigns(:events) + end + + test "should get new" do + login_as :quentin + get :new + assert_response :success + end + + test "new pre-fills tag_list and explains it via flash when auto_tag_source is given" do + login_as :quentin + node = create_node_with_published_page + + get :new, params: { node_id: node.id, tag_list: "open-day", auto_tag_source: "erfa-detail" } + + assert_response :success + assert_equal "open-day", assigns(:event).tag_list.to_s + assert_match "open-day", flash[:notice] + assert_match "erfa-detail", flash[:notice] + end + + test "new does not flash without an auto_tag_source" do + login_as :quentin + + get :new, params: { tag_list: "open-day" } + + assert_response :success + assert_nil flash[:notice] + end + + test "new with no params renders a blank, unflashed form" do + login_as :quentin + + get :new + + assert_response :success + assert_nil assigns(:event).tag_list.presence + assert_nil flash[:notice] + end + + test "should show event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :show, params: { id: event.id } + assert_response :success + end + + test "should get edit" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :edit, params: { id: event.id } + assert_response :success + end + + test "should create event attached to a node" do + login_as :quentin + node = create_node_with_published_page + + assert_difference('Event.count') do + post :create, params: { + event: { + node_id: node.id, + start_time: Time.now, + end_time: Time.now + 1.hour, + tag_list: "open-day" + } + } + end + + assert_redirected_to edit_node_path(node) + assert_equal 'Event was successfully created.', flash[:notice] + end + + test "should not create an event without a title or a node_id" do + login_as :quentin + + assert_no_difference('Event.count') do + post :create, params: { event: { start_time: Time.now, end_time: Time.now + 1.hour } } + end + + assert_response :success # re-renders :new, not a redirect + end + + test "should honour return_to on create" do + login_as :quentin + node = create_node_with_published_page + + post :create, params: { + event: { node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour }, + return_to: node_path(node) + } + + assert_redirected_to node_path(node) + end + + test "should update event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + put :update, params: { id: event.id, event: { title: "Updated title" } } + + assert_redirected_to events_path + assert_equal "Updated title", event.reload.title + end + + test "should destroy event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + assert_difference('Event.count', -1) do + delete :destroy, params: { id: event.id } + end + + assert_redirected_to events_url + end end 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 diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb index 500dbc7..279ff73 100644 --- a/test/models/concerns/rrule_humanizer_test.rb +++ b/test/models/concerns/rrule_humanizer_test.rb @@ -81,4 +81,19 @@ class RruleHumanizerTest < ActiveSupport::TestCase test "falls back to english for unknown locale" do assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :fr) end + + test "wday_abbr returns the correct German abbreviation for each day" do + monday = Time.parse("2026-07-06") # confirmed Monday + assert_equal "Mo", RruleHumanizer.wday_abbr(monday, :de) + assert_equal "Di", RruleHumanizer.wday_abbr(monday + 1.day, :de) + assert_equal "Mi", RruleHumanizer.wday_abbr(monday + 2.days, :de) + assert_equal "Do", RruleHumanizer.wday_abbr(monday + 3.days, :de) + assert_equal "Fr", RruleHumanizer.wday_abbr(monday + 4.days, :de) + assert_equal "Sa", RruleHumanizer.wday_abbr(monday + 5.days, :de) + assert_equal "So", RruleHumanizer.wday_abbr(monday + 6.days, :de) + end + + test "wday_abbr falls back to :de for an unrecognized locale" do + assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr) + end end diff --git a/test/models/helpers/content_helper_test.rb b/test/models/helpers/content_helper_test.rb index 2da82d7..a7ed478 100644 --- a/test/models/helpers/content_helper_test.rb +++ b/test/models/helpers/content_helper_test.rb @@ -1,4 +1,8 @@ require 'test_helper' class ContentHelperTest < ActionView::TestCase + test "weekday_abbr delegates through the current I18n locale" do + I18n.locale = :de + assert_equal "Mo", weekday_abbr(Time.parse("2026-07-06")) + end end diff --git a/test/models/helpers/nodes_helper_test.rb b/test/models/helpers/nodes_helper_test.rb index 13011de..5d91a88 100644 --- a/test/models/helpers/nodes_helper_test.rb +++ b/test/models/helpers/nodes_helper_test.rb @@ -1,4 +1,25 @@ require 'test_helper' class NodesHelperTest < ActionView::TestCase + FakePage = Struct.new(:tag_list) + + test "default_event_tag_mapping matches erfa-detail" do + page = FakePage.new(["erfa-detail"]) + assert_equal ["erfa-detail", "open-day"], default_event_tag_mapping(page) + end + + test "default_event_tag_mapping matches chaostreff-detail" do + page = FakePage.new(["chaostreff-detail"]) + assert_equal ["chaostreff-detail", "open-day"], default_event_tag_mapping(page) + end + + test "default_event_tag_mapping returns nil for unrelated tags" do + page = FakePage.new(["update"]) + assert_nil default_event_tag_mapping(page) + end + + test "default_event_tag_list is nil without a matching tag" do + page = FakePage.new([]) + assert_nil default_event_tag_list(page) + end end -- cgit v1.3 From 4596b32a0787a574881284d042bb77c970df8a4d Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 4 Jul 2026 01:25:24 +0200 Subject: Fix safe_return_to being uncallable from controllers Defined only in ApplicationHelper, which Rails auto-mixes into views but not controllers - so events#create and events#update, which call it directly, have been broken since introduction. Likely unexercised until now because every existing event was created via node.events.create! in the seed script, never through a real POST. Moved to ApplicationController as a protected method + helper_method declaration, so both controllers and views can call it (form_error_ messages stays in ApplicationHelper - it's genuinely view-only, content_tag isn't available in a controller either). Logic unchanged, caught by the new EventsController tests in the previous commit. --- app/controllers/application_controller.rb | 12 ++++++++++++ app/helpers/application_helper.rb | 10 ---------- 2 files changed, 12 insertions(+), 10 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 75f92c3..cbeb40d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,6 +5,8 @@ class ApplicationController < ActionController::Base before_action :set_locale + helper_method :safe_return_to + protected def set_locale @@ -18,4 +20,14 @@ class ApplicationController < ActionController::Base def default_url_options { locale: I18n.locale == I18n.default_locale ? nil : I18n.locale } end + + def safe_return_to(url) + return events_path if url.blank? + uri = URI.parse(url) + return events_path if uri.host.present? + return events_path unless url.start_with?('/') + url + rescue URI::InvalidURIError + events_path + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 72b76b8..0be66e9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,14 +11,4 @@ module ApplicationHelper end end end - - def safe_return_to(url) - return events_path if url.blank? - uri = URI.parse(url) - return events_path if uri.host.present? - return events_path unless url.start_with?('/') - url - rescue URI::InvalidURIError - events_path - end end -- cgit v1.3 From 1fa2f3821497d5529a6753cee84cf5ca679e8bcc Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 5 Jul 2026 21:49:21 +0200 Subject: Fix admin search results colliding with other search widgets layouts/admin.html.erb's top-bar search results div shared id "search_results" with nodes#new/nodes#edit's parent-search widget - since the layout renders on every admin page, whichever widget's JS ran last silently won the shared ID, putting top-bar results into the parent-search box on affected pages. Renamed to "menu_search_results" and updated admin_search.js to match. Also modernizes admin_search.js's event bindings from keyup/keydown/ keypress/paste/cut + .attr("value") to input + .val() throughout (menu_items, parent_search, move_to_search) - the multi-event binding was working around old IE compatibility that .val() + "input" already handles correctly. parent_search's result rendering now matches menu_search's convention (real

/ markup with a .result_path span) instead of a bare in a throwaway wrapper div, so the same CSS rule now correctly applies to both. menu_search's JSON response gains node_path per result, matching what admin_search's own results already provide - not yet consumed by parent_search/move_to_search, which still render click-to-select links rather than navigable ones (correct for their purpose - selecting a value, not leaving the page). Known remaining gap, not fixed here: menu_items, parent_search, and move_to_search still all target the literal id "search_results" between themselves. No live collision today since none of the three currently share a page, but it's the same fragility as the bug above - tracked alongside the existing menu_items/parent_search/move_to_search consolidation backlog item rather than treated as resolved. --- app/controllers/admin_controller.rb | 33 ++++++------ app/views/layouts/admin.html.erb | 2 +- public/javascripts/admin_search.js | 105 +++++++++++++++++++++--------------- 3 files changed, 81 insertions(+), 59 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index d671384..e0098b0 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -29,46 +29,49 @@ class AdminController < ApplicationController .order("updated_at desc") .uniq.first(50) end - + + def conventions + @node_kinds = CccConventions::NODE_KINDS + end + def search @results = Node.search params[:search_term], :per_page => 1000 - + respond_to do |format| format.html do render :template => 'admin/search_results' end - format.js do - render( :json => @results.map do |node| + format.js do + render( :json => @results.map do |node| if node { :id => node.id, :title => node.title, :unique_name => node.unique_name, :node_path => node_path(node) } end end ) - - end + + end end end - + def menu_search if params[:search_term] == "Root" @results = [Node.root] else @results = Node.search params[:search_term] end - + respond_to do |format| format.html do render :partial => 'admin/menu_search_results' end - - format.js do - render( :json => @results.map do |node| - {:node_id => node.id, :title => node.title, :unique_name => node.unique_name} + + format.js do + render( :json => @results.map do |node| + {:node_id => node.id, :title => node.title, :unique_name => node.unique_name, :node_path => node_path(node)} end ) - - end + + end end end - end diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 4536693..a7ce68a 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -49,7 +49,7 @@ Search: <%= text_field_tag :search_term, nil, autocomplete: "off" %> <% end %> -

diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 9bf878b..2565929 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -36,8 +36,8 @@ admin_search = { } }); } else { - $('#search_results').slideUp(); - $('#search_results').empty(); + $('#menu_search_results').slideUp(); + $('#menu_search_results').empty(); } }); }, @@ -52,33 +52,33 @@ admin_search = { }, show_results : function(results) { - $('#search_results').empty(); + $('#menu_search_results').empty(); if (results.length) { - $('#search_results').append( + $('#menu_search_results').append( "

Press Enter to see all results ⏎

" ); } for (result in results) { - $('#search_results').append( + $('#menu_search_results').append( "

" + results[result].title + "" + results[result].unique_name + "" + "

" ); } - $('#search_results').slideDown(); + $('#menu_search_results').slideDown(); } }; menu_items = { initialize_search : function() { - $("#menu_search_term").bind("keyup", function() { - if ($(this).attr("value")) { + $("#menu_search_term").bind("input", function() { + if ($(this).val()) { $.ajax({ type: "GET", url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).attr("value"), + data: "search_term=" + $(this).val(), dataType: "json", success : function(results) { menu_items.show_results(results); @@ -125,12 +125,12 @@ parent_search = { initialize_search : function() { parent_search.initialize_radio_buttons(); - $("#parent_search_term").bind("keyup", function() { - if ($(this).attr("value")) { + $("#parent_search_term").bind("input", function() { + if ($(this).val()) { $.ajax({ type: "GET", url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).attr("value"), + data: "search_term=" + $(this).val(), dataType: "json", success : function(results) { parent_search.show_results(results); @@ -142,22 +142,31 @@ parent_search = { $('#search_results').empty(); } }); + + $("#title").bind("input", function() { + parent_search.update_resulting_path(); + }); + + $("#copy_resulting_path").bind("click", function() { + var path = $("#resulting_path").text(); + if (path === "—" || !navigator.clipboard) return; + navigator.clipboard.writeText(path); + }); }, show_results : function(results) { $("#search_results").empty(); var found = false; for (result in results) { - var link = $((""+ results[result].title + "")); - $(link).bind("click", parent_search.link_closure(results[result])); + var link = $(( + "

" + results[result].title + + "" + results[result].unique_name + "" + + "

")); - // Sometimes I don't get jquery; wrap() didn't work *sigh* - // Guess I'll need a book someday or another framework - var wrapper = $("
"); - $(wrapper).append(link); + $(link).bind("click", parent_search.link_closure(results[result])); - $("#search_results").append(wrapper); + $("#search_results").append(link); found = true; } if (found) @@ -166,51 +175,61 @@ parent_search = { link_closure : function(node) { var barf = function(){ - $("#parent_search_term").attr("value", node.title); - $("#parent_id").attr("value", node.node_id); + $("#parent_search_term").val(node.title); + $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); $('#search_results').slideUp(); $('#search_results').empty(); + parent_search.update_resulting_path(); return false; } return barf; }, - initialize_radio_buttons : function() { - $("#kind_top_level").bind("change", function(){ - $("#parent_search_field").hide(); - }); + update_resulting_path : function() { + var kind = $("input[name='kind']:checked"); + var title = $("#title").val(); - $("#kind_update").bind("change", function(){ - $("#parent_search_field").hide(); - }); + if (title === "") { + $("#resulting_path").text("—"); + return; + } - $("#kind_press_release").bind("change", function(){ - $("#parent_search_field").hide(); - }); + var prefix = kind.val() === "generic" + ? ($("#parent_id").attr("data-unique-name") || "") + : (kind.attr("data-path-prefix") || ""); - $("#kind_generic").bind("change", function(){ - $("#parent_search_field").show(); - }); + clearTimeout(parent_search.path_timeout); + parent_search.path_timeout = setTimeout(function() { + $.get("/nodes/parameterize_preview", { title: title }, function(slug) { + $("#resulting_path").text(window.location.origin + "/" + (prefix ? prefix + "/" : "") + slug); + }); + }, 300); + }, + initialize_radio_buttons : function() { + $("input[name='kind']").bind("change", function(){ + if ($(this).val() === "generic") { + $("#parent_search_field").show(); + } else { + $("#parent_search_field").hide(); + } + parent_search.update_resulting_path(); + }); } } move_to_search = { initialize_search : function() { - $("#move_to_search_term").bind("keyup", function() { move_to_search.do_search($(this))}); - $("#move_to_search_term").bind("keydown", function() { move_to_search.do_search($(this))}); - $("#move_to_search_term").bind("keypress", function() { move_to_search.do_search($(this))}); - $("#move_to_search_term").bind("paste", function() { move_to_search.do_search($(this))}); - $("#move_to_search_term").bind("cut", function() { move_to_search.do_search($(this))}); + $("#move_to_search_term").bind("input", function() {move_to_search.do_search($(this))}); }, do_search : function(_this) { - if (_this.attr("value")) { + if (_this.val()) { $.ajax({ type: "GET", url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + _this.attr("value"), + data: "search_term=" + _this.val(), dataType: "json", success : function(results) { move_to_search.show_results(results); @@ -248,8 +267,8 @@ move_to_search = { link_closure : function(node) { var barf = function(){ - $("#move_to_search_term").attr("value", node.title); - $("#node_staged_parent_id").attr("value", node.node_id); + $("#move_to_search_term").val(node.title); + $("#node_staged_parent_id").val(node.node_id); $('#search_results').slideUp(); $('#search_results').empty(); return false; -- cgit v1.3 From 51d491a3d67e8c9efde8019ecb8a8e1a8195ec99 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 5 Jul 2026 21:51:15 +0200 Subject: Add erfa/chaostreff node-kind creation conventions lib/ccc_conventions.rb: NODE_KINDS registry replaces the kind-specific branches previously scattered across nodes_controller#create (tags), unique_path-position check). Each kind declares its parent lookup (a Proc - Update's "update"/"press_release" continue to genuinely find-or-create their year-folder via Update.find_or_create_parent; erfa/chaostreff use a plain find_by_unique_name!, since their parent nodes are fixed and a missing one should fail loudly, not be silently created), its tags, and (new) its default template. Node gains a default_template_name column (migration, with backfill for existing update-tree nodes via node.update? - reusing that method rather than re-deriving its unique_path check in raw SQL). Page#set_template now inherits from node.default_template_name, falling back to the old update?-based check only when the column is blank, and only fills in template_name when nothing's already been explicitly chosen - a deliberate change from the previous behavior, which unconditionally overwrote template_name on every save regardless of manual selection. Node#update? itself is unchanged and still used as-is by admin_controller's sitemap filtering - a genuinely different, still valid use of that check. "generic" stays special-cased in the controller, parametrized by params[:parent_id] at request time - doesn't fit "kind implies a fixed lookup" and isn't in the registry. nodes#new's four hardcoded radio buttons and nodes_controller's kind-specific case/when branches are both replaced by iterating/looking up this one registry - adding a new kind now means one new hash entry, not four scattered edits. --- app/controllers/nodes_controller.rb | 35 ++++++++++++++++++++--------------- app/models/page.rb | 5 ++--- config/routes.rb | 4 ++++ 3 files changed, 26 insertions(+), 18 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 494887d..1e1def2 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -33,17 +33,17 @@ class NodesController < ApplicationController @node = Node.new @node.parent_id = find_parent - @node.slug = params[:title].parameterize.to_s - + @node.slug = slug_for(params[:title]) + + config = CccConventions::NODE_KINDS[params[:kind]] + if @node.save @node.draft.update(:title => params[:title]) - case params[:kind] - when "update" - @node.draft.tag_list.add("update") - when "press_release" - @node.draft.tag_list.add("update", "pressemitteilung") - end + Array(config && config[:tags]).each { |t| @node.draft.tag_list.add(t) } @node.draft.save! + + @node.update!(default_template_name: config[:template]) if config && config[:template] + redirect_to(edit_node_path(@node)) else render :new @@ -103,9 +103,17 @@ class NodesController < ApplicationController redirect_to node_path(@node) end - + + def parameterize_preview + render plain: slug_for(params[:title]) + end + private + def slug_for(title) + title.to_s.parameterize + end + def node_params params.fetch(:node, {}).permit(:slug, :parent_id, :staged_slug, :staged_parent_id) end @@ -120,18 +128,15 @@ class NodesController < ApplicationController def find_parent case params[:kind] - when "top_level" - Node.root.id - when "update" - Update.find_or_create_parent.id - when "press_release" - Update.find_or_create_parent.id when "generic" if params[:parent_id] && Node.find(params[:parent_id]) params[:parent_id] else nil end + else + config = CccConventions::NODE_KINDS[params[:kind]] + config && config[:parent] ? config[:parent].call.id : nil end end end diff --git a/app/models/page.rb b/app/models/page.rb index c982c2e..20461bf 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -225,9 +225,8 @@ class Page < ApplicationRecord end def set_template - if node && node.update? - self.template_name = "update" - end + return if template_name.present? + self.template_name = node&.default_template_name || (node&.update? ? "update" : nil) end def rewrite_links_in_body diff --git a/config/routes.rb b/config/routes.rb index a7775b3..cf4733c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,10 @@ Cccms::Application.routes.draw do end resources :nodes do + collection do + get :parameterize_preview + end + member do put :unlock put :publish -- cgit v1.3 From d95947f9d738fd74d1fc16c6a1843b7f46bc0484 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 5 Jul 2026 21:52:56 +0200 Subject: Whitespace fixes --- app/controllers/admin_controller.rb | 4 ++-- app/controllers/nodes_controller.rb | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index e0098b0..bfc6cd8 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -1,7 +1,7 @@ class AdminController < ApplicationController - + # Private - + before_action :login_required def index diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 1e1def2..380a659 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -1,14 +1,14 @@ class NodesController < ApplicationController - + # Private - + layout 'admin' - + before_action :login_required before_action :find_node, :only => [ - :show, - :edit, - :update, + :show, + :edit, + :update, :destroy, :publish, :unlock @@ -27,10 +27,10 @@ class NodesController < ApplicationController @parent_name = Node.find(@parent_id).title end end - + def create params[:title] ||= "" - + @node = Node.new @node.parent_id = find_parent @node.slug = slug_for(params[:title]) @@ -49,7 +49,7 @@ class NodesController < ApplicationController render :new end end - + def show node = Node.find(params[:id]) node.wipe_draft! @@ -87,20 +87,20 @@ class NodesController < ApplicationController def destroy @node.destroy end - + def publish @node.publish_draft! flash[:notice] = "Draft has been published" redirect_to node_path(@node) end - + def unlock if @node.unlock! flash[:notice] = "Node unlocked" else flash[:notice] = "Already unlocked" end - + redirect_to node_path(@node) end @@ -121,11 +121,11 @@ class NodesController < ApplicationController def page_params params.fetch(:page, {}).permit(:title, :abstract, :body, :template_name, :published_at, :user_id) end - + def find_node @node = Node.find(params[:id]) end - + def find_parent case params[:kind] when "generic" -- cgit v1.3 From 9e63a6bec1b4ccc45dd684f7b6a941b75f9b9cf0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 06:09:21 +0200 Subject: Add public preview links for drafts Lets editors share a draft with people outside the admin system, via a random per-page token rather than an enumerable id - /preview/. shared_previews#show is intentionally unauthenticated. Redirects to the real public URL once the page is published. Surfaced on nodes#show (Admin Preview + Public Preview, next to Public Link) as generate/revoke buttons. --- app/controllers/nodes_controller.rb | 18 ++++++++++++++++++ app/controllers/shared_previews_controller.rb | 13 +++++++++++++ app/views/nodes/show.html.erb | 17 +++++++++++++++++ config/routes.rb | 4 ++++ 4 files changed, 52 insertions(+) create mode 100644 app/controllers/shared_previews_controller.rb (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 380a659..69aa268 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -104,6 +104,24 @@ class NodesController < ApplicationController redirect_to node_path(@node) end + def generate_shared_preview + @node = Node.find(params[:id]) + if @node.draft + @node.draft.ensure_preview_token! + flash[:notice] = "Shareable preview link created - see below." + else + flash[:notice] = "Create or edit a draft first - shared preview links are only available for pages with an active draft." + end + redirect_to node_path(@node) + end + + def revoke_shared_preview + @node = Node.find(params[:id]) + @node.draft.revoke_preview_token! if @node.draft + flash[:notice] = "Shareable preview link revoked." + redirect_to node_path(@node) + end + def parameterize_preview render plain: slug_for(params[:title]) end diff --git a/app/controllers/shared_previews_controller.rb b/app/controllers/shared_previews_controller.rb new file mode 100644 index 0000000..a8a540f --- /dev/null +++ b/app/controllers/shared_previews_controller.rb @@ -0,0 +1,13 @@ +class SharedPreviewsController < ApplicationController + def show + @page = Page.find_by!(preview_token: params[:token]) + + if @page.node && @page.node.head_id == @page.id + redirect_to node_path(@page.node) + return + end + + response.headers['X-Robots-Tag'] = 'noindex' + render template: @page.valid_template, layout: "application" + end +end diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index c533a55..d63df86 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -12,6 +12,23 @@ Public Link <%= link_to @page.public_link, content_url(@node.unique_path) %> + <% if @node.draft %> + + Admin Preview + <%= link_to preview_page_path(@node.draft), preview_page_path(@node.draft) %> + + + Public Preview + + <% if @node.draft.preview_token.present? %> + <%= link_to shared_preview_path(token: @node.draft.preview_token), shared_preview_url(token: @node.draft.preview_token), target: "_blank", rel: "noopener" %> + <%= button_to 'Revoke', revoke_shared_preview_node_path(@node), method: :put, form: { data: { confirm: "Revoke this preview link? Anyone currently using it will immediately lose access." } } %> + <% else %> + <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put %> + <% end %> + + + <% end %> Author <%= @page.user.try(:login) %> diff --git a/config/routes.rb b/config/routes.rb index 6c07414..26c3d4d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,8 @@ Cccms::Application.routes.draw do member do put :unlock put :publish + put :generate_shared_preview + put :revoke_shared_preview end resources :revisions do @@ -50,6 +52,8 @@ Cccms::Application.routes.draw do end end + get 'preview/:token', to: 'shared_previews#show', as: :shared_preview + scope '/admin' do resources :assets end -- cgit v1.3 From 889e15eabbe107d2642fdd8aa3f03821058c00dc Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 13:52:28 +0200 Subject: Fix shared preview links breaking after a second publish A token's page could stop being node.head_id (superseded by a newer draft) while still being published_at.present? - the old check only compared against the current head, so a superseded page fell through to direct rendering instead of redirecting, serving a stale frozen snapshot indefinitely instead of the current live content. Also handles scheduled publishing correctly: a page can be head_id but not yet public? (published_at in the future) - that case must still render directly, not redirect into a 404 on the not-yet-live public URL. --- app/controllers/shared_previews_controller.rb | 9 +++++-- test/models/page_test.rb | 35 ++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/shared_previews_controller.rb b/app/controllers/shared_previews_controller.rb index a8a540f..d482466 100644 --- a/app/controllers/shared_previews_controller.rb +++ b/app/controllers/shared_previews_controller.rb @@ -1,9 +1,14 @@ class SharedPreviewsController < ApplicationController def show @page = Page.find_by!(preview_token: params[:token]) + node = @page.node - if @page.node && @page.node.head_id == @page.id - redirect_to node_path(@page.node) + was_published = @page.published_at.present? + superseded = was_published && node && node.head_id != @page.id + currently_public = was_published && node && node.head_id == @page.id && @page.public? + + if superseded || currently_public + redirect_to node_path(node) return end diff --git a/test/models/page_test.rb b/test/models/page_test.rb index afba8b5..ad2742f 100644 --- a/test/models/page_test.rb +++ b/test/models/page_test.rb @@ -142,5 +142,38 @@ class PageTest < ActiveSupport::TestCase update = updates2009.children.create!( :slug => "my-first-update" ) assert_equal "update", update.draft.template_name end - + + test "a page scheduled for future publication is not yet public even after being published" do + node = Node.root.children.create!(slug: "preview-scheduled-test") + draft = node.find_or_create_draft(@user1) + draft.title = "Scheduled test" + draft.published_at = 1.day.from_now + draft.save! + token = draft.ensure_preview_token! + + node.publish_draft! + page = Page.find_by(preview_token: token) + + assert_equal page.id, page.node.head_id + assert_not page.public? + end + + test "a superseded page is no longer the head, even though it was once published" do + node = Node.root.children.create!(slug: "preview-superseded-test") + first_draft = node.find_or_create_draft(@user1) + first_draft.title = "First version" + first_draft.save! + first_token = first_draft.ensure_preview_token! + node.publish_draft! + + second_draft = node.find_or_create_draft(@user1) + second_draft.title = "Second version" + second_draft.save! + node.publish_draft! + + first_page = Page.find_by(preview_token: first_token) + + assert_not_equal first_page.id, first_page.node.head_id + assert first_page.published_at.present? + end end -- cgit v1.3 From 7b6af89509e8439fe2474e623ee97e4db67ab011 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 02:46:37 +0200 Subject: Replace awesome_nested_set with a plain parent_id-based NestedTree concern Root-caused this session: appending a child to any node never widened that parent's own rgt boundary, on the pinned revision (Gemfile tracked main directly, chasing a too-conservative gemspec constraint - not, as first assumed, a deliberate pin to avoid a known bug). Reproduced cleanly on a single ordinary create with no concurrency and no bulk operation involved, confirmed via the gem's own SetValidator, then confirmed as the root cause of nodes_controller_test.rb's 3 long-standing "pre-existing" failures - not three separate mysteries, one bug. admin_controller's sitemap needed its own real conversion, not just a drop-in: awesome_nested_set's lft column implicitly provided correct depth-first tree order for free, which the old code combined with a separate class-level each_with_level iterator. Both replaced by one method, self_and_descendants_ordered_with_level, computing an ordered [node, level] list in a single query-then-walk pass - checked against the actual view template first (admin/index.html.erb) rather than assumed, since it relies on list order alone to render correct visual nesting. lft/rgt/depth columns intentionally left in schema, unused - dropping them is a separate, deliberately deferred migration once this is proven running for a while, not bundled with the behavior change. --- Gemfile | 3 -- Gemfile.lock | 10 ----- app/controllers/admin_controller.rb | 8 ++-- app/models/concerns/nested_tree.rb | 86 +++++++++++++++++++++++++++++++++++++ app/models/node.rb | 25 +++-------- db/seeds/chapters.rb | 3 -- 6 files changed, 95 insertions(+), 40 deletions(-) create mode 100644 app/models/concerns/nested_tree.rb (limited to 'app/controllers') diff --git a/Gemfile b/Gemfile index 13bb4ca..d136eb5 100644 --- a/Gemfile +++ b/Gemfile @@ -44,9 +44,6 @@ gem 'will_paginate', '~> 3.0' gem 'acts-as-taggable-on', git: 'https://github.com/mbleigh/acts-as-taggable-on.git', branch: 'master' -gem 'awesome_nested_set', - git: 'https://github.com/collectiveidea/awesome_nested_set.git', - branch: 'main' # ── XML / parsing ───────────────────────────────────────────────────────────── diff --git a/Gemfile.lock b/Gemfile.lock index 2e9a1ce..02c71ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,3 @@ -GIT - remote: https://github.com/collectiveidea/awesome_nested_set.git - revision: 7a642749af74519b4bf3642d939061beb36835e1 - branch: main - specs: - awesome_nested_set (3.9.0) - activerecord (>= 4.0.0, < 8.2) - GIT remote: https://github.com/erdgeist/chaoscalendar.git revision: 48fe5c517ae3895cc78d9d2fefbdd35b90d52ba7 @@ -335,7 +327,6 @@ PLATFORMS DEPENDENCIES acts-as-taggable-on! acts_as_list - awesome_nested_set! chaos_calendar! coffee-rails (~> 4.0) concurrent-ruby (~> 1.3) @@ -374,7 +365,6 @@ CHECKSUMS activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e acts-as-taggable-on (13.0.0) acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8 - awesome_nested_set (3.9.0) base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index bfc6cd8..3fa0519 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -15,12 +15,10 @@ class AdminController < ApplicationController Time.now, Time.now - 14.days ).limit(50).order("updated_at desc") - all_nodes = Node.root.self_and_descendants + ordered_with_level = Node.root.self_and_descendants_ordered_with_level @sitemap_depth = {} - Node.each_with_level(all_nodes) do |node, level| - @sitemap_depth[node.id] = level - end - @sitemap = all_nodes.to_a.sort! { |node1,node2| node1.lft <=> node2.lft }.delete_if { |node| node.update? } + ordered_with_level.each { |node, level| @sitemap_depth[node.id] = level } + @sitemap = ordered_with_level.map(&:first).reject(&:update?) @mypages = Page.where("user_id = ? or editor_id = ?", @current_user, @current_user) diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb new file mode 100644 index 0000000..9a2eb0e --- /dev/null +++ b/app/models/concerns/nested_tree.rb @@ -0,0 +1,86 @@ +# app/models/concerns/nested_tree.rb +# +# Minimal parent_id-based replacement for the tree-traversal subset of +# awesome_nested_set actually used by this app (descendants, ancestors, +# level, root, move_to_child_of) +module NestedTree + extend ActiveSupport::Concern + + included do + belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children + has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent + + before_destroy :delete_descendants + end + + class_methods do + def root + roots.first + end + + def roots + where(parent_id: nil) + end + + def valid? + # Every non-root row's parent_id must point at a real, existing row. + where.not(parent_id: nil).where.missing(:parent).none? + end + end + + def root? + parent_id.nil? + end + + def ancestors + result = [] + current = parent + while current + result << current + current = current.parent + end + result + end + + def level + ancestors.size + end + + def descendants + ids = [] + queue = self.class.where(parent_id: id).pluck(:id) + until queue.empty? + ids.concat(queue) + queue = self.class.where(parent_id: queue).pluck(:id) + end + self.class.where(id: ids) + end + + def self_and_descendants + self.class.where(id: [id] + descendants.pluck(:id)) + end + + def self_and_descendants_ordered_with_level + nodes = [self] + descendants.to_a + children_by_parent = nodes.group_by(&:parent_id) + children_by_parent.each_value { |list| list.sort_by!(&:id) } + + result = [] + visit = ->(node, level) do + result << [node, level] + (children_by_parent[node.id] || []).each { |child| visit.call(child, level + 1) } + end + visit.call(self, 0) + result + end + + def move_to_child_of(new_parent) + update!(parent_id: new_parent.id) + end + + private + + def delete_descendants + self.class.where(id: descendants.pluck(:id)).delete_all + end +end diff --git a/app/models/node.rb b/app/models/node.rb index 2177f15..fc23dc1 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -1,6 +1,6 @@ class Node < ApplicationRecord # Mixins and Plugins - acts_as_nested_set + include NestedTree # Associations has_many :pages, -> { order("revision ASC") }, :dependent => :destroy @@ -21,16 +21,6 @@ class Node < ApplicationRecord validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } validates_presence_of :parent_id, :unless => -> { Node.root.nil? } - validate :borders # This should never ever happen. - - # Index for Fulltext Search - # define_index do - # indexes head.translations.title - # indexes slug - # indexes unique_name - # indexes head.translations.body - # end - # Class methods # Returns a page for a given node. If no revision is supplied, it returns @@ -254,10 +244,13 @@ class Node < ApplicationRecord # Watch out recursion ahead! update_unique_name itself triggers this # after_save callback which invokes update_unique_name on its children. # Hopefully until no childrens occur + # + # Queries parent_id directly rather than the NestedTree#children + # association out of habit from the old awesome_nested_set-avoidance + # workaround - no longer strictly necessary now that children is + # equally safe, but left as-is since it already works correctly. def update_unique_names_of_children unless root? - # Use parent_id-based traversal instead of lft/rgt descendants - # due to awesome_nested_set not refreshing parent lft/rgt in memory Node.where(:parent_id => self.id).each do |child| child.reload child.update_unique_name @@ -265,10 +258,4 @@ class Node < ApplicationRecord end end end - - def borders - if lft && rgt && (lft > rgt) - errors.add("Fuck!. lft should never be smaller than rgt") - end - end end diff --git a/db/seeds/chapters.rb b/db/seeds/chapters.rb index bebc8da..4d81e3c 100644 --- a/db/seeds/chapters.rb +++ b/db/seeds/chapters.rb @@ -1409,6 +1409,3 @@ puts "Nodes flagged for review:" (erfas + chaostreffs).select { |e| e[:review] }.each do |e| puts " #{e[:slug]}" end - -Node.rebuild!(false) - -- cgit v1.3 From 1bf719d6ac58187cf406d92a40b665d3fa6e658b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 03:48:09 +0200 Subject: Add context-aware child-creation shortcuts to nodes#show parent_match Procs on CccConventions::NODE_KINDS, matched against unique_path, decide which "add child" kinds show on a given node. Fixes nodes#new not honoring a pre-selected kind (radio group and parent-field visibility both defaulted to "generic" unconditionally). --- app/controllers/nodes_controller.rb | 1 + app/helpers/nodes_helper.rb | 5 +++ app/views/nodes/new.html.erb | 4 +-- app/views/nodes/show.html.erb | 29 +++++++++++++----- lib/ccc_conventions.rb | 61 ++++++++++++++++++++----------------- public/javascripts/admin_search.js | 18 +++++++---- public/stylesheets/admin.css | 8 +++++ 7 files changed, 82 insertions(+), 44 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 69aa268..a72be68 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -22,6 +22,7 @@ class NodesController < ApplicationController def new @node = Node.new node_params + @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" if params.has_key?(:parent_id) @parent_id = params[:parent_id] @parent_name = Node.find(@parent_id).title diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 2baf813..a89f879 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -58,4 +58,9 @@ module NodesHelper t(:event_schedule_none) end end + + def matching_node_kinds(node) + path = node.unique_path + CccConventions::NODE_KINDS.select { |_, config| config[:parent_match]&.call(path) } + end end diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 0a05325..71f2fbf 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -13,7 +13,7 @@
<% CccConventions::NODE_KINDS.each do |kind, config| %>

- <%= radio_button_tag :kind, kind, kind == "generic", + <%= radio_button_tag :kind, kind, kind == @selected_kind, data: { path_prefix: resolve_kind_text(config[:path_prefix]) } %> <%= resolve_kind_text(config[:label]) %> <% if config[:hint] %> @@ -29,7 +29,7 @@ A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field.

-
+
">
Parent
<%= text_field_tag :parent_search_term, @parent_name %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 189adb8..963bc37 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -101,17 +101,30 @@ <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %>
- <% if @node.children.any? %> + <% matches = matching_node_kinds(@node) %> + <% if @node.children.any? || matches.any? %>
Children
-
- <%= pluralize(@node.children.count, 'child', 'children') %> -
    - <% @node.children.order(:slug).each do |child| %> -
  • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
  • + <% if @node.children.any? %> +
    + <%= pluralize(@node.children.count, 'child', 'children') %> +
      + <% @node.children.order(:slug).each do |child| %> +
    • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
    • + <% end %> +
    +
    + <% end %> + <% if matches.any? %> +
-
+

+ <% end %>
<% end %> diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb index c0f9943..b420452 100644 --- a/lib/ccc_conventions.rb +++ b/lib/ccc_conventions.rb @@ -4,44 +4,49 @@ module CccConventions NODE_KINDS = { "top_level" => { - parent: -> { Node.root }, - path_prefix: "", - label: "Top Level" + parent: -> { Node.root }, + parent_match: ->(path) { path == [] }, + path_prefix: "", + label: "Top Level" }, "generic" => { - label: "Generic", - hint: "Can be created anywhere - choose the parent below." - # no path_prefix - depends on whatever parent gets chosen; see below + parent_match: ->(path) { true }, + label: "Generic", + hint: "Can be created anywhere - choose the parent below." }, "update" => { - parent: -> { Update.find_or_create_parent }, - tags: ["update"], - path_prefix: -> { "updates/#{Time.now.year}" }, - label: "Update", - hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tag \"update\", and inherits the update template." } + parent: -> { Update.find_or_create_parent }, + parent_match: ->(path) { path[0] == "updates" && (path.length == 1 || path[1] =~ /\A\d{4}\z/) }, + tags: ["update"], + path_prefix: -> { "updates/#{Time.now.year}" }, + label: "Update", + hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tag \"update\", and inherits the update template." } }, "press_release" => { - parent: -> { Update.find_or_create_parent }, - tags: ["update", "pressemitteilung"], - path_prefix: -> { "updates/#{Time.now.year}" }, - label: "Pressemitteilung", - hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tags \"update, pressemitteilung\", and inherits the update template." } + parent: -> { Update.find_or_create_parent }, + parent_match: ->(path) { path[0] == "updates" && (path.length == 1 || path[1] =~ /\A\d{4}\z/) }, + tags: ["update", "pressemitteilung"], + path_prefix: -> { "updates/#{Time.now.year}" }, + label: "Pressemitteilung", + hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tags \"update, pressemitteilung\", and inherits the update template." } }, "erfa" => { - parent: -> { Node.find_by_unique_name!(ERFA_PARENT_NAME) }, - tags: ["erfa-detail"], - template: "chapter_detail", - path_prefix: ERFA_PARENT_NAME, - label: "Erfa", - hint: "Automatically created under the Erfa-Kreise overview page, gets tag \"erfa-detail\", and uses the chapter detail template." + parent: -> { Node.find_by_unique_name!(ERFA_PARENT_NAME) }, + parent_match: ->(path) { path == ["club", "erfas"] }, + tags: ["erfa-detail"], + template: "chapter_detail", + path_prefix: ERFA_PARENT_NAME, + label: "Erfa", + hint: "Automatically created under the Erfa-Kreise overview page, gets tag \"erfa-detail\", and uses the chapter detail template." }, "chaostreff" => { - parent: -> { Node.find_by_unique_name!(CHAOSTREFF_PARENT_NAME) }, - tags: ["chaostreff-detail"], - template: "chapter_detail", - path_prefix: CHAOSTREFF_PARENT_NAME, - label: "Chaostreff", - hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." + parent: -> { Node.find_by_unique_name!(CHAOSTREFF_PARENT_NAME) }, + parent_match: ->(path) { path == ["club", "chaostreffs"] }, + tags: ["chaostreff-detail"], + template: "chapter_detail", + path_prefix: CHAOSTREFF_PARENT_NAME, + label: "Chaostreff", + hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." } }.freeze end diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 2565929..6ef9087 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -208,14 +208,20 @@ parent_search = { }, initialize_radio_buttons : function() { - $("input[name='kind']").bind("change", function(){ - if ($(this).val() === "generic") { - $("#parent_search_field").show(); - } else { - $("#parent_search_field").hide(); - } + parent_search.sync_parent_field(); + $("input[name='kind']").bind("change", function() { + parent_search.sync_parent_field(); parent_search.update_resulting_path(); }); + }, + + sync_parent_field : function() { + var kind = $("input[name='kind']:checked").val(); + if (kind === "generic") { + $("#parent_search_field").show(); + } else { + $("#parent_search_field").hide(); + } } } diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1196d83..3033798 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -338,6 +338,14 @@ input[type=radio] { border: 1px solid #989898; } +.add_child_links { + margin-top: 0.5rem; +} + +.add_child_links a { + white-space: nowrap; +} + div#login_form input[type=text], div#login_form input[type=password] { width: 150px; } -- 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 'app/controllers') 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 bc03601ee5c7acd4ef012ec4a404bd7b76bceaa0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 17:32:17 +0200 Subject: Add revert!/discard and rebuild nodes#edit around the new hierarchy revert! discards exactly the topmost non-empty layer -- autosave if present, else draft -- and reveals whatever's beneath it, releasing the lock only once nothing is left to protect. Guards against destroying a brand-new, never-published node's only draft, which would violate the (head | draft) invariant every other method here assumes holds; the view's Destroy/Discard button is gated the same way. nodes#edit now calls lock_for_editing! instead of find_or_create_draft, and always displays autosave || draft || head, resurrecting an abandoned session's unsaved content by default with an explicit flash explaining what's shown and how to get back to the last saved version. The view drops content_for :subnavigation entirely: Show becomes "Unlock + Back", Preview stays a plain link, metadata's own
already replaced the old toggle, and Publish moves off this page for good, per the earlier decision to manage the publish lifecycle entirely from nodes#show. Save Draft and Save + Unlock + Exit appear both above and below the form, given the body field alone runs 600px on desktop. --- app/controllers/nodes_controller.rb | 34 ++++++++++----- config/routes.rb | 1 + test/models/node_test.rb | 87 +++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 10 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 042963b..6fd000b 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -12,7 +12,8 @@ class NodesController < ApplicationController :destroy, :publish, :unlock, - :autosave + :autosave, + :revert ] def index @@ -59,16 +60,17 @@ class NodesController < ApplicationController end def edit - begin - @draft = @node.find_or_create_draft( current_user ) - rescue LockedByAnotherUser => e - flash[:error] = e.message - if request.referer - redirect_to request.referer || node_path(@node) - else - redirect_to node_path(@node) - end + @node.lock_for_editing!( current_user ) + @page = @node.autosave || @node.draft || @node.head + + if @node.autosave + flash.now[:notice] = + "This page has unsaved changes from a previous session, shown below. " \ + "Save to keep them, or use \"Discard changes\" below to go back to the last saved version." end + rescue LockedByAnotherUser => e + flash[:error] = e.message + redirect_to(request.referer || node_path(@node)) end def update @@ -104,6 +106,18 @@ class NodesController < ApplicationController render plain: "Autosave failed", status: :internal_server_error end + def revert + @node.revert!(current_user) + if @node.draft + redirect_to edit_node_path(@node) + else + redirect_to node_path(@node) + end + rescue LockedByAnotherUser => e + flash[:error] = e.message + redirect_to node_path(@node) + end + def destroy @node.destroy end diff --git a/config/routes.rb b/config/routes.rb index 1569a15..da46e5c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,6 +41,7 @@ Cccms::Application.routes.draw do put :generate_shared_preview put :revoke_shared_preview put :autosave + put :revert end resources :revisions do diff --git a/test/models/node_test.rb b/test/models/node_test.rb index bdf556d..2138c19 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -379,6 +379,93 @@ class NodeTest < ActiveSupport::TestCase assert_nil node.autosave assert_nil node.lock_owner end + + test "revert! is a safe no-op on a fresh node with only a draft" do + node = create_node_with_draft + node.lock_for_editing!(@user1) + + node.revert!(@user1) + node.reload + + assert_not_nil node.draft + assert_equal @user1, node.lock_owner + end + + test "revert! discards an autosave on a fresh node without touching its only draft" do + node = create_node_with_draft + node.lock_for_editing!(@user1) + node.autosave!({ :title => "typing" }, @user1) + + node.revert!(@user1) + node.reload + + assert_nil node.autosave + assert_not_nil node.draft + assert_equal @user1, node.lock_owner + end + + test "revert! does nothing when a published node has no draft or autosave" do + node = create_node_with_published_page + node.lock_for_editing!(@user1) + + node.revert!(@user1) + node.reload + + assert_not_nil node.head + assert_nil node.draft + end + + test "revert! discards a fresh autosave and releases the lock when no draft exists" do + node = create_node_with_published_page + node.lock_for_editing!(@user1) + node.autosave!({ :title => "in progress" }, @user1) + + node.revert!(@user1) + node.reload + + assert_nil node.autosave + assert_nil node.draft + assert_nil node.lock_owner + end + + test "revert! destroys an existing draft and releases the lock" do + node = create_node_with_published_page + head_title = node.head.title + node.lock_for_editing!(@user1) + node.autosave!({ :title => "second version" }, @user1) + node.save_draft!(@user1) + + node.revert!(@user1) + node.reload + + assert_nil node.draft + assert_equal head_title, node.head.title + assert_nil node.lock_owner + end + + test "revert! discards only the autosave when a draft survives beneath it" do + node = create_node_with_published_page + node.lock_for_editing!(@user1) + node.autosave!({ :title => "second version" }, @user1) + node.save_draft!(@user1) + node.autosave!({ :title => "third version, still typing" }, @user1) + + node.revert!(@user1) + node.reload + + assert_nil node.autosave + assert_not_nil node.draft + assert_equal "second version", node.draft.title + assert_equal @user1, node.lock_owner + end + + test "revert! raises LockedByAnotherUser for a non-owner" do + node = create_node_with_published_page + node.lock_for_editing!(@user1) + + assert_raise(LockedByAnotherUser) { node.revert!(@user2) } + assert_equal @user1, node.reload.lock_owner + end def create_revisions node, count count.times do -- cgit v1.3 From 0bc112baec8b3d19aba3c1757cb4cf81fda098c5 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 22:49:49 +0200 Subject: Fix blank-title validation and its lost form state on re-render A blank title failed presence and length validation simultaneously, showing two redundant messages for one problem; length now skips whenever slug is already blank, matching presence's own skip condition. Separately, create's failure path never computed @selected_kind/@parent_id/@parent_name the way new does, so re-rendering after a validation error silently lost which kind and parent had been chosen -- for the auto-tagging/auto-templating kinds, that meant a corrected resubmission could silently produce a plain generic node instead. required on the title field closes the common case without a round trip; the server-side fix remains the actual guarantee, since required is trivially bypassed. --- app/controllers/nodes_controller.rb | 5 +++++ app/models/node.rb | 2 +- app/views/nodes/new.html.erb | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 6fd000b..b56d779 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -49,6 +49,11 @@ class NodesController < ApplicationController redirect_to(edit_node_path(@node)) else + @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" + if params[:parent_id].present? + @parent_id = params[:parent_id] + @parent_name = Node.find(@parent_id).title + end render :new end end diff --git a/app/models/node.rb b/app/models/node.rb index f15c908..7675ab6 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -17,7 +17,7 @@ class Node < ApplicationRecord after_save :update_unique_names_of_children # Validations - validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? } + validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } validates_presence_of :slug, :unless => -> { parent_id.nil? } validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } validates_presence_of :parent_id, :unless => -> { Node.root.nil? } diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 71f2fbf..afc632f 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -25,7 +25,7 @@
Title
- <%= text_field_tag :title %> + <%= text_field_tag :title, nil, required: true %> A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field.
-- cgit v1.3 From 9427dc462e68eb4b902cd5b2ace5607b036504ef Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 22:51:17 +0200 Subject: Fix revert being unusable from an unlocked nodes#show revert! requires the lock to already be held, correct for nodes#edit where the editor holds it throughout -- but both exclusive-case buttons on nodes#show fire from a node that starts out unlocked, which raised LockedByAnotherUser incorrectly. lock_for_editing! first makes both call sites safe: a harmless re-stamp when already the owner, a real but momentary acquisition otherwise, released again by revert! itself once nothing is left to protect. --- app/controllers/nodes_controller.rb | 1 + 1 file changed, 1 insertion(+) (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index b56d779..d8586e2 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -112,6 +112,7 @@ class NodesController < ApplicationController end def revert + @node.lock_for_editing!(current_user) @node.revert!(current_user) if @node.draft redirect_to edit_node_path(@node) -- cgit v1.3 From 168ff3f8b6037bc3c2b5bce74adac37e48366dac Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 22:52:00 +0200 Subject: Add the Status section to nodes#show and rebuild nodes#edit's action bar Retires content_for :subnavigation on nodes#show entirely -- Preview was fully redundant with Links' own preview URLs in every state, and Edit is now a permanent, always-rendered action inside the new Status section rather than a link floating at the top, consistent with the "action lives next to the info" pattern People already established for Unlock. Status surfaces head/draft/autosave plainly and gates Edit/Publish/Destroy/Discard on lock ownership specifically, not mere lock presence -- @node.locked? alone would have blocked the lock owner's own session, caught by the click-test and fixed here rather than shipped. nodes#edit's action bar is rebuilt to sit outside form_for (both button_to calls render their own nested form, invalid HTML the browser was silently stripping) with Save wired back in via form="..." rather than needing to live inside the form tag at all. Also brings the locked-and-ready-to-edit flash message, and the visual polish from this session's click-test: consistent button heights across the bordered and pill-shaped variants sharing a row, spacing between Status's data and its actions, and error_messages styling reusing the destructive-red vocabulary already established elsewhere. --- app/controllers/nodes_controller.rb | 2 + app/views/nodes/edit.html.erb | 45 ++++++++++--------- app/views/nodes/show.html.erb | 58 +++++++++++++++++++++--- public/stylesheets/admin.css | 88 ++++++++++++++++++++++++++++++++++--- 4 files changed, 159 insertions(+), 34 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index d8586e2..72d4a3e 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -68,6 +68,8 @@ class NodesController < ApplicationController @node.lock_for_editing!( current_user ) @page = @node.autosave || @node.draft || @node.head + flash.now[:notice] = "Node locked and ready to edit" unless @node.autosave + if @node.autosave flash.now[:notice] = "This page has unsaved changes from a previous session, shown below. " \ diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index feba92a..cdc9b36 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -1,3 +1,21 @@ +

<%= title_for_node(@node) %>

+ +
+ <%= button_to 'Unlock + Back', unlock_node_path(@node), method: :put, + form: { class: 'button_to state_changing' }, + disabled: @node.autosave.present? %> + + <% if @node.autosave || (@node.draft && @node.head) %> + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% end %> + + <%= submit_tag "Save Draft", form: dom_id(@node, :edit) %> + <%= submit_tag "Save + Unlock + Exit", form: dom_id(@node, :edit) %> + <%= link_to "Preview ↗", preview_page_path(@page), target: "_blank", rel: "noopener", class: "preview_link" %> +
+
<%= form_for(@node, html: { data: { autosave_url: autosave_node_path(@node), show_url: node_path(@node) } }) do |f| %> <% if @node.errors.any? %> @@ -6,25 +24,6 @@
<% end %> -

<%= title_for_node(@node) %>

- -
- <%= button_to 'Unlock + Back', unlock_node_path(@node), method: :put, - form: { class: 'button_to state_changing' }, - disabled: @node.autosave.present? %> - - <% if @node.autosave || (@node.draft && @node.head) %> - <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), - revert_node_path(@node), method: :put, - form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> - <% end %> - - <%= link_to 'Preview', preview_page_path(@page) %> - - <%= f.submit 'Save Draft' %> - <%= f.submit 'Save + Unlock + Exit' %> -
-
Metadata (slug, parent, tags, template, author, images)
@@ -102,9 +101,9 @@
<%= d.text_area :body, :class => 'with_editor' %>
<% end %> -
- <%= f.submit 'Save Draft' %> - <%= f.submit 'Save + Unlock + Exit' %> -
+
+ <%= f.submit 'Save Draft' %> + <%= f.submit 'Save + Unlock + Exit' %> +
<% end %>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 2ab7986..036caf2 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -1,11 +1,59 @@ -<% content_for :subnavigation do %> - <%= link_to 'Edit', edit_node_path(@node), :class => "unselected" %> - <%= link_to 'Preview', preview_page_path(@page) %> -<% end %> - +<% locked_by_other = @node.locked? && @node.lock_owner != current_user %>

<%= title_for_node(@node) %>

+
Status
+
+
+
+ Head + <%= @node.head ? "#{@node.head.title} (rev #{@node.head.revision}, #{@node.head.updated_at})" : "none — never published" %> +
+
+ Draft + <%= @node.draft ? "#{@node.draft.title} (rev #{@node.draft.revision}, saved #{@node.draft.updated_at})" : "none" %> +
+
+ Autosave + <%= @node.autosave ? "#{@node.autosave.title} (unsaved, #{@node.autosave.updated_at})" : "none" %> +
+
+ + <% edit_label = @node.autosave ? "Continue Editing" : (@node.draft ? "Edit Draft" : "Edit") %> +
+
+ <% if locked_by_other %> + <%= edit_label %> + <% else %> + <%= link_to (@node.autosave ? "Continue Editing" : (@node.draft ? "Edit Draft" : "Lock + Edit")), edit_node_path(@node), class: "action_button" %> + <% if !@node.draft && !@node.autosave %> + Nothing pending — this will start a fresh draft. + <% end %> + <% end %> +
+ + <% unless locked_by_other %> + <% if @node.draft && !@node.autosave %> +
+ <%= button_to 'Publish', publish_node_path(@node), method: :put, + form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> +
+ <% end %> + <% if @node.draft || @node.autosave %> +
+ <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> +
+ <% end %> + <% end %> +
+ + <% if locked_by_other %> + Locked — see People below to unlock before editing, publishing, or discarding. + <% end %> +
+
People
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index ceacf17..a6e8bf6 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -79,6 +79,9 @@ input[type=radio] { #wrapper { margin: 0 125px; } + .node_action_bar.node_action_bar_save { + margin-left: 120px; + } } @media(max-width:1015px) { #wrapper { @@ -165,12 +168,14 @@ input[type=radio] { margin-bottom: 40px; } -#node_action_bar { - margin-bottom: 1rem; -} - -#node_action_bar > * { - margin-right: 0.5rem; +.node_action_bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin: 1rem 0 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e8e8e8; } /* ============================================================ @@ -213,6 +218,18 @@ span.warning a { text-decoration: underline; } +.error_messages { + border-left: 3px solid #cc0000; + background-color: #fdecea; + padding: 6px 10px; + margin-bottom: 1rem; +} + +.error_messages ul { + margin: 0; + padding-left: 1.25rem; +} + /* ============================================================ Pagination ============================================================ */ @@ -487,6 +504,16 @@ table.user_table td.user_login { padding: 0.5rem 0.75rem; } +.node_info_group .disabled_action { + display: inline-block; + border: 1px solid #c0c0c0; + border-radius: 2px; + padding: 4px 12px; + font-weight: bold; + color: #969696; + cursor: not-allowed; +} + .node_info_group_items { display: flex; flex-wrap: wrap; @@ -514,6 +541,38 @@ table.user_table td.user_login { margin-bottom: 0.25rem; } +.node_content.node_status { + border: 2px solid #000000; + background-color: #fafafa; +} + +.node_status .node_info_group_items:first-child { + margin-bottom: 0.75rem; +} + +.node_status .node_info_group_items + .node_info_group_items { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.node_status .field_hint { + margin-top: 0.5rem; + display: block; +} + +#page_editor a.action_button, +.node_status form.button_to input[type="submit"], +.node_status form.button_to button[type="submit"] { + padding: 4px 12px; + line-height: 1.2; + box-sizing: border-box; +} + +.node_status form.button_to input[type="submit"], +.node_status form.button_to button[type="submit"] { + border: 1px solid transparent; +} + .field_hint { display: block; margin-top: 2px; @@ -562,6 +621,23 @@ div#page_editor { margin-left: 10px; } +#page_editor a.action_button { + display: inline-block; + -webkit-appearance: none; + appearance: none; + border: 1px solid #000000; + border-radius: 2px; + padding: 4px 12px; + font-weight: bold; + text-decoration: none; + color: #000000; +} + +#page_editor a.action_button:hover { + color: #ffffff; + background-color: #000000; +} + @media(min-width:1016px) { input#tag_list, input#node_staged_slug, -- cgit v1.3 From 8310751d4db2854597d6379b24e346c65622972d Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 14:19:36 +0200 Subject: Major resource view cleanup Remove dead pages#index and occurrences resource; fix menu_items#new menu_items#new called menu_item_params, which requires a submitted menu_item key that can never be present on a fresh GET -- new only ever needs a blank record to render against. pages#index and the entire occurrences resource were unmodified Rails scaffold generator output, unlinked from any nav and unreachable except by direct URL. pages#index had no controller action at all (@pages was never assigned); occurrences#index queried every row with no scoping or pagination and its layout referenced a scaffold.css asset that no longer exists in the pipeline. Neither is a real editorial surface -- occurrences are auto-generated from an event's RRULE and were never meant to be browsed as a flat list. Removed rather than repaired. pages#preview and pages#sort_images remain, now as explicit routes rather than under a full resources :pages block. --- app/controllers/menu_items_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/views/assets/index.html.erb | 6 ++-- app/views/assets/show.html.erb | 50 ++++++++++++++++------------- app/views/events/index.html.erb | 4 ++- app/views/menu_items/edit.html.erb | 51 +++++++++++++----------------- app/views/menu_items/index.html.erb | 9 ++---- app/views/nodes/index.html.erb | 5 +++ app/views/occurrences/index.html.erb | 2 +- app/views/pages/index.html.erb | 2 +- app/views/users/edit.html.erb | 54 ++++++++++++++------------------ app/views/users/index.html.erb | 17 +++++----- app/views/users/new.html.erb | 52 ++++++++++++++---------------- app/views/users/show.html.erb | 39 ++++++++++++----------- config/routes.rb | 9 ++---- public/stylesheets/admin.css | 32 +++++++++++++++++++ 16 files changed, 177 insertions(+), 159 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/menu_items_controller.rb b/app/controllers/menu_items_controller.rb index 1b1eb59..314d1ea 100644 --- a/app/controllers/menu_items_controller.rb +++ b/app/controllers/menu_items_controller.rb @@ -14,7 +14,7 @@ class MenuItemsController < ApplicationController end def new - @menu_item = MenuItem.new menu_item_params + @menu_item = MenuItem.new end def create diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f01691f..6572b7a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -15,7 +15,7 @@ class UsersController < ApplicationController end def new - @user = User.new + @user = User.new(admin: params[:admin].present?) end def create diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb index 4301fee..6591091 100644 --- a/app/views/assets/index.html.erb +++ b/app/views/assets/index.html.erb @@ -1,6 +1,6 @@ -<% content_for :subnavigation do %> - <%= link_to 'New asset', new_asset_path %> -<% end %> +

Assets

+ +<%= link_to 'New asset', new_asset_path, class: 'action_button' %> <%= will_paginate @assets %> diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb index 694be5a..0286678 100644 --- a/app/views/assets/show.html.erb +++ b/app/views/assets/show.html.erb @@ -1,23 +1,29 @@ -<% content_for :subnavigation do %> - <%= link_to 'Edit', edit_asset_path(@asset) %> - <%= link_to 'Back', assets_path %> -<% end %> +
+

<%= @asset.name %>

- - - - - - - - - - - - - - - - - -
Thumbnail<%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %>
Public Path<%= @asset.upload.url.sub(/\?\d+$/, "") %>
Content Type<%= @asset.upload.content_type %>
Size<%= "#{@asset.upload.size/1024} KB" %>
+
+
Actions
+
+
+
+ <%= link_to 'Edit', edit_asset_path(@asset), class: 'action_button' %> +
+
+ <%= link_to 'Back', assets_path %> +
+
+
+ +
Thumbnail
+
<%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %>
+ +
Public Path
+
<%= @asset.upload.url.sub(/\?\d+$/, "") %>
+ +
Content Type
+
<%= @asset.upload.content_type %>
+ +
Size
+
<%= "#{@asset.upload.size/1024} KB" %>
+
+
diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index c3e0e5a..b6a5b1f 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,4 +1,6 @@ -

Listing events

+

Events

+ +<%= link_to 'New event', new_event_path, class: 'action_button' %> diff --git a/app/views/menu_items/edit.html.erb b/app/views/menu_items/edit.html.erb index 9891708..dc5e8f9 100644 --- a/app/views/menu_items/edit.html.erb +++ b/app/views/menu_items/edit.html.erb @@ -1,32 +1,25 @@

Edit Menu Item

-<%= form_for @menu_item do |f| %> -
- - - - - - - - - - - - - - - - - - - - - -
Search - <%= text_field_tag :menu_search_term %> -
+
+ <%= form_for @menu_item do |f| %> +
+
Search
+
+ <%= text_field_tag :menu_search_term %> +
+
-
-
Node Id<%= f.text_field :node_id %>
Path<%= f.text_field :path %>
Title<%= f.text_field :title %>
<%= f.submit 'Update' %>
-<% end %> +
Node Id
+
<%= f.text_field :node_id %>
+ +
Path
+
<%= f.text_field :path %>
+ +
Title
+
<%= f.text_field :title %>
+ +
+
<%= f.submit 'Update' %>
+ <% end %> +
+
diff --git a/app/views/menu_items/index.html.erb b/app/views/menu_items/index.html.erb index c52c150..e84199b 100644 --- a/app/views/menu_items/index.html.erb +++ b/app/views/menu_items/index.html.erb @@ -1,10 +1,7 @@ -<% content_for :subnavigation do %> - <%= link_to "new", new_menu_item_path %> -<% end %> - -

Menu Items

+<%= link_to 'New menu item', new_menu_item_path, class: 'action_button' %> + <% @menu_items.each do |menu_item| %> @@ -16,7 +13,7 @@ <% end %> diff --git a/app/views/nodes/index.html.erb b/app/views/nodes/index.html.erb index 2399105..903e80b 100644 --- a/app/views/nodes/index.html.erb +++ b/app/views/nodes/index.html.erb @@ -3,6 +3,11 @@ <% end %>

Nodes

+
+ <%= link_to 'New node', new_node_path, class: 'action_button' %> + Creates a node with no parent or kind set — for a specific page, use the wizard's create flow or "add child" from an existing node instead. +
+ <%= will_paginate @nodes %> diff --git a/app/views/occurrences/index.html.erb b/app/views/occurrences/index.html.erb index 0e99857..82818d0 100644 --- a/app/views/occurrences/index.html.erb +++ b/app/views/occurrences/index.html.erb @@ -1,4 +1,4 @@ -

Listing occurrences

+

Occurrences

diff --git a/app/views/pages/index.html.erb b/app/views/pages/index.html.erb index 91e5359..05c7311 100644 --- a/app/views/pages/index.html.erb +++ b/app/views/pages/index.html.erb @@ -1,4 +1,4 @@ -

Listing pages

+

Pages

diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index df1005b..77b33c6 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -2,38 +2,32 @@ <% if @user.errors.any? %>
-
    <% @user.errors.full_messages.each do |msg| %>
  • <%= msg.gsub("Slug", "Title") %>
  • <% end %>
+
    <% @user.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %>
<% end %> +
+ <%= form_for @user do |f| %> +
+
Login
+
<%= f.text_field :login %>
-<%= form_for @user do |f| %> -
- - - - - - - - - - - - - - - - - <% if current_user.admin? %> - - - - +
E-Mail
+
<%= f.text_field :email %>
+ +
Password
+
<%= f.password_field :password %>
+ +
Confirm
+
<%= f.password_field :password_confirmation %>
+ + <% if current_user.admin? %> +
Admin?
+
<%= f.check_box :admin %>
+ <% end %> + +
+
<%= f.submit "Update" %>
<% end %> - - - - -
Login<%= f.text_field :login %>
E-Mail<%= f.text_field :email %>
Password<%= f.password_field :password %>
Confirm<%= f.password_field :password_confirmation %>
Admin?<%= f.check_box :admin %>
<%= f.submit "Update" %>
-<% end %> +
+
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 88868f5..46e958b 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,7 +1,6 @@ +

Admins

+<%= link_to 'New admin user', new_user_path(admin: true), class: 'action_button' %> - - - @@ -9,9 +8,11 @@ <%= render :partial => "user", :locals => {:users => @users[:admin] ||= []} %> - - - +

Admins

Login

Users

+ +

Users

+<%= link_to 'New user', new_user_path, class: 'action_button' %> + @@ -20,7 +21,3 @@ <%= render :partial => "user", :locals => {:users => @users[:user] ||= []} %>
Login
- -<% content_for :subnavigation do %> - <%= link_to "create", new_user_path %> -<% end %> diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index 6beda4f..ce38fdf 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb @@ -2,36 +2,30 @@ <% if @user.errors.any? %>
-
    <% @user.errors.full_messages.each do |msg| %>
  • <%= msg.gsub("Slug", "Title") %>
  • <% end %>
+
    <% @user.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %>
<% end %> +
+ <%= form_for @user do |f| %> +
+
Login
+
<%= f.text_field :login %>
-<%= form_for @user do |f| %> - - - - - - - - - - - - - - - - - - - - - - - - - -
Login<%= f.text_field :login %>
E-Mail<%= f.text_field :email %>
Password<%= f.password_field :password %>
Confirm<%= f.password_field :password_confirmation %>
Admin?<%= f.check_box :admin %>
<%= f.submit "Create" %>
-<% end %> +
E-Mail
+
<%= f.text_field :email %>
+ +
Password
+
<%= f.password_field :password %>
+ +
Confirm
+
<%= f.password_field :password_confirmation %>
+ +
Admin?
+
<%= f.check_box :admin %>
+ +
+
<%= f.submit "Create" %>
+ <% end %> +
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 80483d3..e744efe 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,20 +1,23 @@ -<% content_for :subnavigation do %> - <%= link_to 'Edit', edit_user_path(@user) %> -<% end %> +
+

<%= @user.login %>

-

User: <%= @user.login %>

+
+
Actions
+
+
+
+ <%= link_to 'Edit', edit_user_path(@user), class: 'action_button' %> +
+
+
- - - - - - - - - - - - - -
Login<%= @user.login %>
E-Mail<%= @user.email %>
Admin?<%= @user.admin ? "yes" : "no" %>
\ No newline at end of file +
Login
+
<%= @user.login %>
+ +
E-Mail
+
<%= @user.email %>
+ +
Admin?
+
<%= @user.admin ? "yes" : "no" %>
+
+
diff --git a/config/routes.rb b/config/routes.rb index da46e5c..5d68828 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,15 +20,10 @@ Cccms::Application.routes.draw do scope '(:locale)', locale: /de|en/ do resources :tags - resources :occurrences resources :events - resources :pages do - member do - get :preview - put :sort_images - end - end + get 'pages/:id/preview', to: 'pages#preview', as: :preview_page + put 'pages/:id/sort_images', to: 'pages#sort_images', as: :sort_images_page resources :nodes do collection do diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 66f13b8..f00a658 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -640,6 +640,13 @@ form.button_to button[type="submit"] { color: #969696; padding-bottom: 4px; } + +.action_button + .field_hint { + display: inline; + margin-left: 0.5rem; + margin-top: 0; +} + /* Identical declaration block to #search_results p span.result_path / #menu_search_results p span.result_path below -- three independent copies of the same "small gray helper text" style. */ @@ -692,6 +699,7 @@ a.action_button { font-weight: bold; text-decoration: none; color: #000000; + margin-bottom: 1rem; } #page_editor a.action_button:hover, @@ -700,6 +708,11 @@ a.action_button:hover { background-color: #000000; } +#page_editor form input[type=text], +#page_editor form input[type=password] { + padding: 5px; +} + @media(min-width:1016px) { input#tag_list, input#node_staged_slug, @@ -731,6 +744,11 @@ a.action_button:hover { #page_editor #metadata, #page_editor #content, #admin_overview { margin-left: -125px; } + + #page_editor form input[type=text], + #page_editor form input[type=password] { + width: 690px; + } } @media(max-width:1015px) { @@ -773,6 +791,12 @@ a.action_button:hover { font-weight: bold; } + #page_editor form input[type=text], + #page_editor form input[type=password] { + box-sizing: border-box; + width: 100%; + } + input[type=text], textarea { font-size: 1.5rem; } @@ -920,6 +944,14 @@ div#draft_list table td.actions a { cursor: grab; } +#menu_item_list td a { + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-color: #b0b0b0; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + .ui-state-highlight td { height: 20px; } -- cgit v1.3 From 9c3217df50d462d6be4399e3e654d591bf704df7 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 15:29:40 +0200 Subject: Fix malformed-HTML fallout across RSS, link rewriting, and flash messaging Editors' TinyMCE output can contain unclosed void elements like
, which is valid HTML5 but invalid XML -- three different places assumed the stricter rule and broke or silently misbehaved on the looser one. The Atom feed's block required real, well-formed XML structure but was handed a raw, unescaped body string; switched to type="html" with CGI.escapeHTML, matching how title/summary already handle the same content. rewrite_links_in_body used libxml's strict XML parser to rewrite internal links to be locale-prefixed, which raised on exactly this class of malformed markup -- silently, since the whole method was wrapped in rescue; nil, meaning the link rewrite (not the save) quietly failed with no error anywhere. Replaced with Nokogiri's lenient HTML parser, which repairs malformed void elements rather than rejecting them; also drops the bare rescue now that the actual failure mode it was guarding against shouldn't occur, and fixes two adjacent bugs found while in this method: a typo'd /sytem/uploads/ regex that could never match, and a missing https:// exclusion alongside the existing http:// one. Also addresses stale flash messaging surfaced while testing the above: update's save confirmation was being clobbered by edit's own "locked and ready" notice on the very next request, since nothing distinguished a fresh lock acquisition from a redirect back after saving. The save confirmation now names the next step (publish from Status) and flags a stale translation if one exists, using Page#outdated_translations?, already present but previously unused by any controller. --- app/controllers/nodes_controller.rb | 18 ++++++++++++++--- app/models/page.rb | 39 ++++++++++++++----------------------- app/views/layouts/admin.html.erb | 7 +++++++ app/views/rss/updates.xml.builder | 4 +--- public/stylesheets/admin.css | 9 +++++++++ 5 files changed, 47 insertions(+), 30 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 72d4a3e..38d42d9 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -65,15 +65,16 @@ class NodesController < ApplicationController end def edit + freshly_locked = @node.lock_owner != current_user @node.lock_for_editing!( current_user ) @page = @node.autosave || @node.draft || @node.head - flash.now[:notice] = "Node locked and ready to edit" unless @node.autosave - if @node.autosave flash.now[:notice] = "This page has unsaved changes from a previous session, shown below. " \ "Save to keep them, or use \"Discard changes\" below to go back to the last saved version." + elsif freshly_locked + flash.now[:notice] = "Node locked and ready to edit" end rescue LockedByAnotherUser => e flash[:error] = e.message @@ -85,7 +86,18 @@ class NodesController < ApplicationController @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}" + flash[:notice] = "Draft saved. Publish your changes in the Status section once you're done." + flash[:status_path] = node_path(@node) + + if @node.draft.translated_locales.size > 1 + stale_locale = @node.draft.translated_locales.find do |locale| + @node.draft.outdated_translations?(locale: locale) + end + if stale_locale + flash[:stale_locale] = stale_locale + flash[:stale_locale_path] = edit_node_path(@node, locale: stale_locale) + end + end if params[:commit] == "Save + Unlock + Exit" @node.unlock! diff --git a/app/models/page.rb b/app/models/page.rb index fff044e..1a98e08 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -243,31 +243,22 @@ class Page < ApplicationRecord end def rewrite_links_in_body - begin - if self.body - tmp_body = "
#{self.body}
" - xml_string = XML::Parser.string( tmp_body ) - xml_doc = xml_string.parse - links = xml_doc.find("//a[not(starts-with(@href, 'http://'))]") - links = links.reject { |l| l[:href] =~ /system\/uploads/ } - locales = I18n.available_locales.reject {|l| l == :root} - - links.each do |link| - unless locales.include? link[:href].slice(1,2).to_sym - unless link[:href] =~ /sytem\/uploads/ - link[:href] = link[:href].sub(/^\//, "/#{I18n.locale}/") - end - end - end - - tmp_body = xml_doc.to_s.gsub(/(\n\|\<\/div\>\n)/, "") - tmp_body.gsub!("", "") - - self.body = tmp_body + return unless self.body + + doc = Nokogiri::HTML::DocumentFragment.parse(self.body) + locales = I18n.available_locales.reject { |l| l == :root } + + doc.css('a').each do |link| + href = link['href'] + next unless href + next if href.start_with?('http://', 'https://') + next if href =~ /system\/uploads/ + + unless locales.include?(href.slice(1, 2)&.to_sym) + link['href'] = href.sub(/^\//, "/#{I18n.locale}/") end - rescue - nil end - end + self.body = doc.to_html + end end diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 340eaf2..79b1380 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -33,6 +33,13 @@ <% if flash[:notice].present? || flash[:error].present? %>
<%= flash[:notice] %> + <% if flash[:status_path] %> + <%= link_to 'Go to Status', flash[:status_path] %> + <% end %> + <% if flash[:stale_locale_path] %> + The <%= flash[:stale_locale] %> translation may be out of date — + <%= link_to 'review it', flash[:stale_locale_path] %> too. + <% end %> <% if flash[:error] %> <%= flash[:error] %> <% end %> diff --git a/app/views/rss/updates.xml.builder b/app/views/rss/updates.xml.builder index 4b2e2f7..27845c4 100644 --- a/app/views/rss/updates.xml.builder +++ b/app/views/rss/updates.xml.builder @@ -22,9 +22,7 @@ xml.feed(:xmlns => "http://www.w3.org/2005/Atom", "xml:base" => @host) do xml.updated(item.updated_at.xmlschema) xml.published(item.published_at.xmlschema) xml.summary(CGI.escapeHTML(item.abstract.to_s)) - xml.content(:type => "xhtml") do - xml.div(item.body, :xmlns => "http://www.w3.org/1999/xhtml") - end + xml.content(CGI.escapeHTML(item.body.to_s), :type => "html") end end diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index f00a658..3f95b0c 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -193,6 +193,15 @@ input[type=radio] { margin-left: 5px; } +#flash a { + text-decoration: underline; + -webkit-text-decoration-style: wavy; + text-decoration-style: wavy; + text-decoration-color: #b0b0b0; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + #flash span { letter-spacing: 1px; margin-right: 10px; -- cgit v1.3 From ae48c9e86411f8f54f29e72a11b1e4ced48fe437 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 15:50:22 +0200 Subject: Paginate Events index --- app/controllers/events_controller.rb | 4 ++-- app/views/events/index.html.erb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index b98a38e..b9b7892 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -9,10 +9,10 @@ class EventsController < ApplicationController # GET /events # GET /events.xml def index - @events = Event.all + @events = Event.order(:id) respond_to do |format| - format.html # index.html.erb + format.html { @events = @events.paginate(page: params[:page], per_page: 25) } format.xml { render :xml => @events } end end diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index b6a5b1f..f579fc0 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -2,6 +2,8 @@ <%= link_to 'New event', new_event_path, class: 'action_button' %> +<%= will_paginate @events %> + @@ -27,5 +29,3 @@ <% end %>
Title
- -<%= link_to 'New event', new_event_path, class: 'action_button' %> -- cgit v1.3 From 6dcbf019deda9ec2809f8735c8b10ba3a5701189 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 18:55:04 +0200 Subject: Add an events view for events without node, part 2 --- app/controllers/events_controller.rb | 5 +++++ app/views/events/index.html.erb | 1 + 2 files changed, 6 insertions(+) (limited to 'app/controllers') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index b9b7892..be3f547 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -17,6 +17,11 @@ class EventsController < ApplicationController end end + # GET /events/without_node + def without_node + @events = Event.where(node_id: nil).order(:start_time).paginate(page: params[:page], per_page: 25) + end + # GET /events/1 # GET /events/1.xml def show diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index f579fc0..8127e3a 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,6 +1,7 @@

Events

<%= link_to 'New event', new_event_path, class: 'action_button' %> +<%= link_to 'View events without a page →', without_node_events_path %> <%= will_paginate @events %> -- cgit v1.3 From a25d90335ad3738b6831288190132c2f7498465c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 00:35:24 +0200 Subject: Retire vendored cacycle_diff.js for a diff-lcs-based diff view Revisions#diff now computes an inline or side-by-side word diff server-side (Page#diff_against, lib/html_word_diff.rb) instead of shipping raw/escaped content to the browser for a 2008-era vendored JS differ to mangle. View mode is picked via a `view` param, settable either on the diff page itself or directly from the revisions#index sticky bar next to "Diff revisions". Also fixes a pre-existing bug in RevisionsController#diff's single- revision branch, which set params[:start]/params[:end] instead of params[:start_revision]/params[:end_revision]. --- Gemfile | 3 +- Gemfile.lock | 3 + app/controllers/revisions_controller.rb | 12 +- app/models/page.rb | 16 + app/views/revisions/diff.html.erb | 78 +- app/views/revisions/index.html.erb | 8 + lib/html_word_diff.rb | 52 ++ public/javascripts/cacycle_diff.js | 1112 ------------------------- public/stylesheets/admin.css | 25 + test/controllers/revisions_controller_test.rb | 29 + test/models/page_test.rb | 34 + 11 files changed, 204 insertions(+), 1168 deletions(-) create mode 100644 lib/html_word_diff.rb delete mode 100644 public/javascripts/cacycle_diff.js (limited to 'app/controllers') diff --git a/Gemfile b/Gemfile index d136eb5..19d92d7 100644 --- a/Gemfile +++ b/Gemfile @@ -45,10 +45,11 @@ gem 'acts-as-taggable-on', git: 'https://github.com/mbleigh/acts-as-taggable-on.git', branch: 'master' -# ── XML / parsing ───────────────────────────────────────────────────────────── +# ── XML / parsing / diffing ─────────────────────────────────────────────────── gem 'libxml-ruby', '~> 5.0', require: 'xml' # body link rewriting in Page model gem 'nokogiri', '~> 1.18' +gem 'diff-lcs', require: 'diff/lcs' # ── Operational ─────────────────────────────────────────────────────────────── diff --git a/Gemfile.lock b/Gemfile.lock index 02c71ff..e6f5fa0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,7 @@ GEM connection_pool (3.0.2) crass (1.0.7) date (3.5.1) + diff-lcs (2.0.0) drb (2.2.3) erb (6.0.4) erubi (1.13.1) @@ -330,6 +331,7 @@ DEPENDENCIES chaos_calendar! coffee-rails (~> 4.0) concurrent-ruby (~> 1.3) + diff-lcs exception_notification (~> 4.5) globalize (~> 7.0) jquery-rails @@ -377,6 +379,7 @@ CHECKSUMS connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + diff-lcs (2.0.0) sha256=708a5d52ec2945b50f8f53a181174aa1ef2c496edf81c05957fe956dabb363d5 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb index 42d667e..9acb26f 100644 --- a/app/controllers/revisions_controller.rb +++ b/app/controllers/revisions_controller.rb @@ -13,16 +13,18 @@ class RevisionsController < ApplicationController def diff @node = Node.find(params[:node_id]) - + if @node.pages.length > 1 params[:start_revision] ||= @node.pages.all[-2].revision params[:end_revision] ||= @node.pages.all[-1].revision else - params[:start], params[:end] = 1, 1 + params[:start_revision], params[:end_revision] = 1, 1 end - - @start = @node.pages.find_by_revision( params[:start_revision] ) - @end = @node.pages.find_by_revision( params[:end_revision] ) + + @start = @node.pages.find_by_revision( params[:start_revision] ) + @end = @node.pages.find_by_revision( params[:end_revision] ) + @diff_view = params[:view] == "side_by_side" ? :side_by_side : :inline + @diff = @end.diff_against( @start, view: @diff_view ) end def show diff --git a/app/models/page.rb b/app/models/page.rb index 1a98e08..ea04cd6 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -175,6 +175,22 @@ class Page < ApplicationRecord self.save end + def diff_against other, view: :inline + if view == :side_by_side + { + title: HtmlWordDiff.side_by_side(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.side_by_side(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.side_by_side(other.body.to_s, body.to_s) + } + else + { + title: HtmlWordDiff.inline(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.inline(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.inline(other.body.to_s, body.to_s) + } + end + end + def public? published_at.nil? ? true : published_at < Time.now end diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d8c6a47..d7bb528 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -7,58 +7,36 @@ <%= form_tag diff_node_revisions_path do %> <%= select_tag :start_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:start_revision].to_i) %> <%= select_tag :end_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:end_revision].to_i) %> + <%= select_tag :view, options_for_select([['Inline', 'inline'], ['Side by side', 'side_by_side']], @diff_view) %> <%= submit_tag 'Diff' %> <% end %> - - - - - - - - -
-  
-
-
-  
-
- - - -
-

Title

-

- -

Abstract

-

- -

Body

-

+ <% if @diff_view == :side_by_side %> +
+
+

Title

+

<%= raw @diff[:title][0] %>

+

Abstract

+

<%= raw @diff[:abstract][0] %>

+

Body

+ <%= raw @diff[:body][0] %> +
+
+

Title

+

<%= raw @diff[:title][1] %>

+

Abstract

+

<%= raw @diff[:abstract][1] %>

+

Body

+ <%= raw @diff[:body][1] %> +
+
+ <% else %> +

Title

+

<%= raw @diff[:title] %>

+

Abstract

+

<%= raw @diff[:abstract] %>

+

Body

+ <%= raw @diff[:body] %> + <% end %>
diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index a6a981a..58c08b7 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -19,6 +19,8 @@ form: { id: 'diff_form', class: 'button_to computation' }, disabled: true %> + + @@ -68,6 +70,7 @@ document.getElementById('diff_form').addEventListener('submit', function(e) { var start = document.querySelector('input[name="start_revision"]:checked'); var end = document.querySelector('input[name="end_revision"]:checked'); + var view = document.querySelector('input[name="view"]:checked'); if (start) { var s = document.createElement('input'); s.type = 'hidden'; s.name = 'start_revision'; s.value = start.value; @@ -78,5 +81,10 @@ en.type = 'hidden'; en.name = 'end_revision'; en.value = end.value; this.appendChild(en); } + if (view) { + var v = document.createElement('input'); + v.type = 'hidden'; v.name = 'view'; v.value = view.value; + this.appendChild(v); + } }); diff --git a/lib/html_word_diff.rb b/lib/html_word_diff.rb new file mode 100644 index 0000000..1f28cf5 --- /dev/null +++ b/lib/html_word_diff.rb @@ -0,0 +1,52 @@ +require 'diff/lcs' + +module HtmlWordDiff + TOKEN_REGEXP = /<[^>]*>|[[:space:]]+|[[:alnum:]]+|[^[:space:][:alnum:]<>]/ + + def self.inline(old_html, new_html) + html = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + html << new_token + when '-' + html << "#{old_token}" + when '+' + html << "#{new_token}" + when '!' + html << "#{old_token}#{new_token}" + end + end + html + end + + def self.side_by_side(old_html, new_html) + old_out = +'' + new_out = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + old_out << old_token + new_out << new_token + when '-' + old_out << "#{old_token}" + when '+' + new_out << "#{new_token}" + when '!' + old_out << "#{old_token}" + new_out << "#{new_token}" + end + end + [old_out, new_out] + end + + def self.each_change(old_html, new_html) + Diff::LCS.sdiff(tokenize(old_html), tokenize(new_html)).each do |change| + yield change.action, change.old_element, change.new_element + end + end + + def self.tokenize(html) + html.to_s.scan(TOKEN_REGEXP) + end +end diff --git a/public/javascripts/cacycle_diff.js b/public/javascripts/cacycle_diff.js deleted file mode 100644 index 24f9d0b..0000000 --- a/public/javascripts/cacycle_diff.js +++ /dev/null @@ -1,1112 +0,0 @@ -//

- 
-/*
- 
-Name:    diff.js
-Version: 0.9.5a (April 6, 2008)
-Info:    http://en.wikipedia.org/wiki/User:Cacycle/diff
-Code:    http://en.wikipedia.org/wiki/User:Cacycle/diff.js
- 
-JavaScript diff algorithm by [[en:User:Cacycle]] (http://en.wikipedia.org/wiki/User_talk:Cacycle).
-Outputs html/css-formatted new text with highlighted deletions, inserts, and block moves.
- 
-The program uses cross-browser code and should work with all modern browsers. It has been tested with:
-* Mozilla Firefox 1.5.0.1
-* Mozilla SeaMonkey 1.0
-* Opera 8.53
-* Internet Explorer 6.0.2900.2180
-* Internet Explorer 7.0.5730.11
-This program is also compatibel with Greasemonkey
- 
-An implementation of the word-based algorithm from:
- 
-Communications of the ACM 21(4):264 (1978)
-http://doi.acm.org/10.1145/359460.359467
- 
-With the following additional feature:
- 
-* Word types have been optimized for MediaWiki source texts
-* Additional post-pass 5 code for resolving islands caused by adding
-  two common words at the end of sequences of common words
-* Additional detection of block borders and color coding of moved blocks and their original position
-* Optional "intelligent" omission of unchanged parts from the output
- 
-This code is used by the MediaWiki in-browser text editors [[en:User:Cacycle/editor]] and [[en:User:Cacycle/wikEd]]
-and the enhanced diff view tool wikEdDiff [[en:User:Cacycle/wikEd]].
- 
-Usage: var htmlText = WDiffString(oldText, newText);
- 
-This code has been released into the public domain.
- 
-Datastructures:
- 
-text: an object that holds all text related datastructures
-  .newWords: consecutive words of the new text (N)
-  .oldWords: consecutive words of the old text (O)
-  .newToOld: array of corresponding word number in old text (NA)
-  .oldToNew: array of corresponding word number in new text (OA)
-  .message:  output message for testing purposes
- 
-symbol['word']: symbol table for passes 1 - 3, holds words as a hash
-  .newCtr:  new word occurences counter (NC)
-  .oldCtr:  old word occurences counter (OC)
-  .toNew:   table last old word number
-  .toOld:   last new word number (OLNA)
- 
-block: an object that holds block move information
-  blocks indexed after new text:
-  .newStart:  new text word number of start of this block
-  .newLength: element number of this block including non-words
-  .newWords:  true word number of this block
-  .newNumber: corresponding block index in old text
-  .newBlock:  moved-block-number of a block that has been moved here
-  .newLeft:   moved-block-number of a block that has been moved from this border leftwards
-  .newRight:  moved-block-number of a block that has been moved from this border rightwards
-  .newLeftIndex:  index number of a block that has been moved from this border leftwards
-  .newRightIndex: index number of a block that has been moved from this border rightwards
-  blocks indexed after old text:
-  .oldStart:  word number of start of this block
-  .oldToNew:  corresponding new text word number of start
-  .oldLength: element number of this block including non-words
-  .oldWords:  true word number of this block
- 
-*/
- 
- 
-// css for change indicators
-if (typeof(wDiffStyleDelete) == 'undefined') { window.wDiffStyleDelete = 'font-weight: normal; text-decoration: none; color: #fff; background-color: #990033;'; }
-if (typeof(wDiffStyleInsert) == 'undefined') { window.wDiffStyleInsert = 'font-weight: normal; text-decoration: none; color: #fff; background-color: #009933;'; }
-if (typeof(wDiffStyleMoved)  == 'undefined') { window.wDiffStyleMoved  = 'font-weight: bold;  color: #000; vertical-align: text-bottom; font-size: xx-small; padding: 0; border: solid 1px;'; }
-if (typeof(wDiffStyleBlock)  == 'undefined') { window.wDiffStyleBlock  = [
-  'color: #000; background-color: #ffff80;',
-  'color: #000; background-color: #c0ffff;',
-  'color: #000; background-color: #ffd0f0;',
-  'color: #000; background-color: #ffe080;',
-  'color: #000; background-color: #aaddff;',
-  'color: #000; background-color: #ddaaff;',
-  'color: #000; background-color: #ffbbbb;',
-  'color: #000; background-color: #d8ffa0;',
-  'color: #000; background-color: #d0d0d0;'
-]; }
- 
-// html for change indicators, {number} is replaced by the block number
-// {block} is replaced by the block style, class and html comments are important for shortening the output
-if (typeof(wDiffHtmlMovedRight)  == 'undefined') { window.wDiffHtmlMovedRight  = ''; }
-if (typeof(wDiffHtmlMovedLeft)   == 'undefined') { window.wDiffHtmlMovedLeft   = ''; }
- 
-if (typeof(wDiffHtmlBlockStart)  == 'undefined') { window.wDiffHtmlBlockStart  = ''; }
-if (typeof(wDiffHtmlBlockEnd)    == 'undefined') { window.wDiffHtmlBlockEnd    = ''; }
- 
-if (typeof(wDiffHtmlDeleteStart) == 'undefined') { window.wDiffHtmlDeleteStart = ''; }
-if (typeof(wDiffHtmlDeleteEnd)   == 'undefined') { window.wDiffHtmlDeleteEnd   = ''; }
- 
-if (typeof(wDiffHtmlInsertStart) == 'undefined') { window.wDiffHtmlInsertStart = ''; }
-if (typeof(wDiffHtmlInsertEnd)   == 'undefined') { window.wDiffHtmlInsertEnd   = ''; }
- 
-// minimal number of real words for a moved block (0 for always displaying block move indicators)
-if (typeof(wDiffBlockMinLength) == 'undefined') { window.wDiffBlockMinLength = 3; }
- 
-// exclude identical sequence starts and endings from change marking
-if (typeof(wDiffWordDiff) == 'undefined') { window.wDiffWordDiff = true; }
- 
-// enable recursive diff to resolve problematic sequences
-if (typeof(wDiffRecursiveDiff) == 'undefined') { window.wDiffRecursiveDiff = true; }
- 
-// enable block move display
-if (typeof(wDiffShowBlockMoves) == 'undefined') { window.wDiffShowBlockMoves = true; }
- 
-// remove unchanged parts from final output
- 
-// characters before diff tag to search for previous heading, paragraph, line break, cut characters
-if (typeof(wDiffHeadingBefore)   == 'undefined') { window.wDiffHeadingBefore   = 1500; }
-if (typeof(wDiffParagraphBefore) == 'undefined') { window.wDiffParagraphBefore = 1500; }
-if (typeof(wDiffLineBeforeMax)   == 'undefined') { window.wDiffLineBeforeMax   = 1000; }
-if (typeof(wDiffLineBeforeMin)   == 'undefined') { window.wDiffLineBeforeMin   =  500; }
-if (typeof(wDiffBlankBeforeMax)  == 'undefined') { window.wDiffBlankBeforeMax  = 1000; }
-if (typeof(wDiffBlankBeforeMin)  == 'undefined') { window.wDiffBlankBeforeMin  =  500; }
-if (typeof(wDiffCharsBefore)     == 'undefined') { window.wDiffCharsBefore     =  500; }
- 
-// characters after diff tag to search for next heading, paragraph, line break, or characters
-if (typeof(wDiffHeadingAfter)   == 'undefined') { window.wDiffHeadingAfter   = 1500; }
-if (typeof(wDiffParagraphAfter) == 'undefined') { window.wDiffParagraphAfter = 1500; }
-if (typeof(wDiffLineAfterMax)   == 'undefined') { window.wDiffLineAfterMax   = 1000; }
-if (typeof(wDiffLineAfterMin)   == 'undefined') { window.wDiffLineAfterMin   =  500; }
-if (typeof(wDiffBlankAfterMax)  == 'undefined') { window.wDiffBlankAfterMax  = 1000; }
-if (typeof(wDiffBlankAfterMin)  == 'undefined') { window.wDiffBlankAfterMin  =  500; }
-if (typeof(wDiffCharsAfter)     == 'undefined') { window.wDiffCharsAfter     =  500; }
- 
-// maximal fragment distance to join close fragments
-if (typeof(wDiffFragmentJoin)  == 'undefined') { window.wDiffFragmentJoin = 1000; }
-if (typeof(wDiffOmittedChars)  == 'undefined') { window.wDiffOmittedChars = '…'; }
-if (typeof(wDiffOmittedLines)  == 'undefined') { window.wDiffOmittedLines = '
'; } -if (typeof(wDiffNoChange) == 'undefined') { window.wDiffNoChange = '
'; } - -// compatibility fix for old name of main function -window.StringDiff = window.WDiffString; - - -// WDiffString: main program -// input: oldText, newText, strings containing the texts -// returns: html diff - -window.WDiffString = function(oldText, newText) { - -// IE / Mac fix - oldText = oldText.replace(/(\r\n)/g, '\n'); - newText = newText.replace(/(\r\n)/g, '\n'); - - var text = {}; - text.newWords = []; - text.oldWords = []; - text.newToOld = []; - text.oldToNew = []; - text.message = ''; - var block = {}; - var outText = ''; - -// trap trivial changes: no change - if (oldText == newText) { - outText = newText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - return(outText); - } - -// trap trivial changes: old text deleted - if ( (oldText == null) || (oldText.length == 0) ) { - outText = newText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - outText = wDiffHtmlInsertStart + outText + wDiffHtmlInsertEnd; - return(outText); - } - -// trap trivial changes: new text deleted - if ( (newText == null) || (newText.length == 0) ) { - outText = oldText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - outText = wDiffHtmlDeleteStart + outText + wDiffHtmlDeleteEnd; - return(outText); - } - -// split new and old text into words - WDiffSplitText(oldText, newText, text); - -// calculate diff information - WDiffText(text); - -//detect block borders and moved blocks - WDiffDetectBlocks(text, block); - -// process diff data into formatted html text - outText = WDiffToHtml(text, block); - -// IE fix - outText = outText.replace(/> ( *) $1<'); - - return(outText); -} - - -// WDiffSplitText: split new and old text into words -// input: oldText, newText, strings containing the texts -// changes: text.newWords and text.oldWords, arrays containing the texts in arrays of words - -window.WDiffSplitText = function(oldText, newText, text) { - -// convert strange spaces - oldText = oldText.replace(/[\t\u000b\u00a0\u2028\u2029]+/g, ' '); - newText = newText.replace(/[\t\u000b\u00a0\u2028\u2029]+/g, ' '); - -// split old text into words - -// / | | | | | | | | | | | | | | / - var pattern = /[\w]+|\[\[|\]\]|\{\{|\}\}|\n+| +|&\w+;|'''|''|=+|\{\||\|\}|\|\-|./g; - var result; - do { - result = pattern.exec(oldText); - if (result != null) { - text.oldWords.push(result[0]); - } - } while (result != null); - -// split new text into words - do { - result = pattern.exec(newText); - if (result != null) { - text.newWords.push(result[0]); - } - } while (result != null); - - return; -} - - -// WDiffText: calculate diff information -// input: text.newWords and text.oldWords, arrays containing the texts in arrays of words -// optionally for recursive calls: newStart, newEnd, oldStart, oldEnd, recursionLevel -// changes: text.newToOld and text.oldToNew, containing the line numbers in the other version - -window.WDiffText = function(text, newStart, newEnd, oldStart, oldEnd, recursionLevel) { - - symbol = new Object(); - symbol.newCtr = []; - symbol.oldCtr = []; - symbol.toNew = []; - symbol.toOld = []; - -// set defaults - newStart = newStart || 0; - newEnd = newEnd || text.newWords.length; - oldStart = oldStart || 0; - oldEnd = oldEnd || text.oldWords.length; - recursionLevel = recursionLevel || 0; - -// limit recursion depth - if (recursionLevel > 10) { - return; - } - -// pass 1: parse new text into symbol table s - - var word; - for (var i = newStart; i < newEnd; i ++) { - word = text.newWords[i]; - -// add new entry to symbol table - if ( symbol[word] == null) { - symbol[word] = { newCtr: 0, oldCtr: 0, toNew: null, toOld: null }; - } - -// increment symbol table word counter for new text - symbol[word].newCtr ++; - -// add last word number in new text - symbol[word].toNew = i; - } - -// pass 2: parse old text into symbol table - - for (var j = oldStart; j < oldEnd; j ++) { - word = text.oldWords[j]; - -// add new entry to symbol table - if ( symbol[word] == null) { - symbol[word] = { newCtr: 0, oldCtr: 0, toNew: null, toOld: null }; - } - -// increment symbol table word counter for old text - symbol[word].oldCtr ++; - -// add last word number in old text - symbol[word].toOld = j; - } - -// pass 3: connect unique words - - for (var i in symbol) { - -// find words in the symbol table that occur only once in both versions - if ( (symbol[i].newCtr == 1) && (symbol[i].oldCtr == 1) ) { - var toNew = symbol[i].toNew; - var toOld = symbol[i].toOld; - -// do not use spaces as unique markers - if ( ! /\s/.test( text.newWords[toNew] ) ) { - -// connect from new to old and from old to new - text.newToOld[toNew] = toOld; - text.oldToNew[toOld] = toNew; - } - } - } - -// pass 4: connect adjacent identical words downwards - - for (var i = newStart; i < newEnd - 1; i ++) { - -// find already connected pairs - if (text.newToOld[i] != null) { - j = text.newToOld[i]; - -// check if the following words are not yet connected - if ( (text.newToOld[i + 1] == null) && (text.oldToNew[j + 1] == null) ) { - -// if the following words are the same connect them - if ( text.newWords[i + 1] == text.oldWords[j + 1] ) { - text.newToOld[i + 1] = j + 1; - text.oldToNew[j + 1] = i + 1; - } - } - } - } - -// pass 5: connect adjacent identical words upwards - - for (var i = newEnd - 1; i > newStart; i --) { - -// find already connected pairs - if (text.newToOld[i] != null) { - j = text.newToOld[i]; - -// check if the preceeding words are not yet connected - if ( (text.newToOld[i - 1] == null) && (text.oldToNew[j - 1] == null) ) { - -// if the preceeding words are the same connect them - if ( text.newWords[i - 1] == text.oldWords[j - 1] ) { - text.newToOld[i - 1] = j - 1; - text.oldToNew[j - 1] = i - 1; - } - } - } - } - -// recursively diff still unresolved regions downwards - - if (wDiffRecursiveDiff) { - i = newStart; - j = oldStart; - while (i < newEnd) { - if (text.newToOld[i - 1] != null) { - j = text.newToOld[i - 1] + 1; - } - -// check for the start of an unresolved sequence - if ( (text.newToOld[i] == null) && (text.oldToNew[j] == null) ) { - -// determine the ends of the sequences - var iStart = i; - var iEnd = i; - while ( (text.newToOld[iEnd] == null) && (iEnd < newEnd) ) { - iEnd ++; - } - var iLength = iEnd - iStart; - - var jStart = j; - var jEnd = j; - while ( (text.oldToNew[jEnd] == null) && (jEnd < oldEnd) ) { - jEnd ++; - } - var jLength = jEnd - jStart; - -// recursively diff the unresolved sequence - if ( (iLength > 0) && (jLength > 0) ) { - if ( (iLength > 1) || (jLength > 1) ) { - if ( (iStart != newStart) || (iEnd != newEnd) || (jStart != oldStart) || (jEnd != oldEnd) ) { - WDiffText(text, iStart, iEnd, jStart, jEnd, recursionLevel + 1); - } - } - } - i = iEnd; - } - else { - i ++; - } - } - } - -// recursively diff still unresolved regions upwards - - if (wDiffRecursiveDiff) { - i = newEnd - 1; - j = oldEnd - 1; - while (i >= newStart) { - if (text.newToOld[i + 1] != null) { - j = text.newToOld[i + 1] - 1; - } - -// check for the start of an unresolved sequence - if ( (text.newToOld[i] == null) && (text.oldToNew[j] == null) ) { - -// determine the ends of the sequences - var iStart = i; - var iEnd = i + 1; - while ( (text.newToOld[iStart - 1] == null) && (iStart >= newStart) ) { - iStart --; - } - var iLength = iEnd - iStart; - - var jStart = j; - var jEnd = j + 1; - while ( (text.oldToNew[jStart - 1] == null) && (jStart >= oldStart) ) { - jStart --; - } - var jLength = jEnd - jStart; - -// recursively diff the unresolved sequence - if ( (iLength > 0) && (jLength > 0) ) { - if ( (iLength > 1) || (jLength > 1) ) { - if ( (iStart != newStart) || (iEnd != newEnd) || (jStart != oldStart) || (jEnd != oldEnd) ) { - WDiffText(text, iStart, iEnd, jStart, jEnd, recursionLevel + 1); - } - } - } - i = iStart - 1; - } - else { - i --; - } - } - } - return; -} - - -// WDiffToHtml: process diff data into formatted html text -// input: text.newWords and text.oldWords, arrays containing the texts in arrays of words -// text.newToOld and text.oldToNew, containing the line numbers in the other version -// block data structure -// returns: outText, a html string - -window.WDiffToHtml = function(text, block) { - - var outText = text.message; - - var blockNumber = 0; - var i = 0; - var j = 0; - var movedAsInsertion; - -// cycle through the new text - do { - var movedIndex = []; - var movedBlock = []; - var movedLeft = []; - var blockText = ''; - var identText = ''; - var delText = ''; - var insText = ''; - var identStart = ''; - -// check if a block ends here and finish previous block - if (movedAsInsertion != null) { - if (movedAsInsertion == false) { - identStart += wDiffHtmlBlockEnd; - } - else { - identStart += wDiffHtmlInsertEnd; - } - movedAsInsertion = null; - } - -// detect block boundary - if ( (text.newToOld[i] != j) || (blockNumber == 0 ) ) { - if ( ( (text.newToOld[i] != null) || (i >= text.newWords.length) ) && ( (text.oldToNew[j] != null) || (j >= text.oldWords.length) ) ) { - -// block moved right - var moved = block.newRight[blockNumber]; - if (moved > 0) { - var index = block.newRightIndex[blockNumber]; - movedIndex.push(index); - movedBlock.push(moved); - movedLeft.push(false); - } - -// block moved left - moved = block.newLeft[blockNumber]; - if (moved > 0) { - var index = block.newLeftIndex[blockNumber]; - movedIndex.push(index); - movedBlock.push(moved); - movedLeft.push(true); - } - -// check if a block starts here - moved = block.newBlock[blockNumber]; - if (moved > 0) { - -// mark block as inserted text - if (block.newWords[blockNumber] < wDiffBlockMinLength) { - identStart += wDiffHtmlInsertStart; - movedAsInsertion = true; - } - -// mark block by color - else { - if (moved > wDiffStyleBlock.length) { - moved = wDiffStyleBlock.length; - } - identStart += WDiffHtmlCustomize(wDiffHtmlBlockStart, moved - 1); - movedAsInsertion = false; - } - } - - if (i >= text.newWords.length) { - i ++; - } - else { - j = text.newToOld[i]; - blockNumber ++; - } - } - } - -// get the correct order if moved to the left as well as to the right from here - if (movedIndex.length == 2) { - if (movedIndex[0] > movedIndex[1]) { - movedIndex.reverse(); - movedBlock.reverse(); - movedLeft.reverse(); - } - } - -// handle left and right block moves from this position - for (var m = 0; m < movedIndex.length; m ++) { - -// insert the block as deleted text - if (block.newWords[ movedIndex[m] ] < wDiffBlockMinLength) { - var movedStart = block.newStart[ movedIndex[m] ]; - var movedLength = block.newLength[ movedIndex[m] ]; - var str = ''; - for (var n = movedStart; n < movedStart + movedLength; n ++) { - str += text.newWords[n]; - } - str = WDiffEscape(str); - str = str.replace(/\n/g, '¶
'); - blockText += wDiffHtmlDeleteStart + str + wDiffHtmlDeleteEnd; - } - -// add a placeholder / move direction indicator - else { - if (movedBlock[m] > wDiffStyleBlock.length) { - movedBlock[m] = wDiffStyleBlock.length; - } - if (movedLeft[m]) { - blockText += WDiffHtmlCustomize(wDiffHtmlMovedLeft, movedBlock[m] - 1); - } - else { - blockText += WDiffHtmlCustomize(wDiffHtmlMovedRight, movedBlock[m] - 1); - } - } - } - -// collect consecutive identical text - while ( (i < text.newWords.length) && (j < text.oldWords.length) ) { - if ( (text.newToOld[i] == null) || (text.oldToNew[j] == null) ) { - break; - } - if (text.newToOld[i] != j) { - break; - } - identText += text.newWords[i]; - i ++; - j ++; - } - -// collect consecutive deletions - while ( (text.oldToNew[j] == null) && (j < text.oldWords.length) ) { - delText += text.oldWords[j]; - j ++; - } - -// collect consecutive inserts - while ( (text.newToOld[i] == null) && (i < text.newWords.length) ) { - insText += text.newWords[i]; - i ++; - } - -// remove leading and trailing similarities betweein delText and ins from highlighting - var preText = ''; - var postText = ''; - if (wDiffWordDiff) { - if ( (delText != '') && (insText != '') ) { - -// remove leading similarities - while ( delText.charAt(0) == insText.charAt(0) && (delText != '') && (insText != '') ) { - preText = preText + delText.charAt(0); - delText = delText.substr(1); - insText = insText.substr(1); - } - -// remove trailing similarities - while ( delText.charAt(delText.length - 1) == insText.charAt(insText.length - 1) && (delText != '') && (insText != '') ) { - postText = delText.charAt(delText.length - 1) + postText; - delText = delText.substr(0, delText.length - 1); - insText = insText.substr(0, insText.length - 1); - } - } - } - -// output the identical text, deletions and inserts - -// moved from here indicator - if (blockText != '') { - outText += blockText; - } - -// identical text - if (identText != '') { - outText += identStart + WDiffEscape(identText); - } - outText += preText; - -// deleted text - if (delText != '') { - delText = wDiffHtmlDeleteStart + WDiffEscape(delText) + wDiffHtmlDeleteEnd; - delText = delText.replace(/\n/g, '¶
'); - outText += delText; - } - -// inserted text - if (insText != '') { - insText = wDiffHtmlInsertStart + WDiffEscape(insText) + wDiffHtmlInsertEnd; - insText = insText.replace(/\n/g, '¶
'); - outText += insText; - } - outText += postText; - } while (i <= text.newWords.length); - - outText += '\n'; - outText = WDiffHtmlFormat(outText); - - return(outText); -} - - -// WDiffEscape: replaces html-sensitive characters in output text with character entities - -window.WDiffEscape = function(text) { - - text = text.replace(/&/g, '&'); - text = text.replace(//g, '>'); - text = text.replace(/\"/g, '"'); - - return(text); -} - - -// HtmlCustomize: customize indicator html: replace {number} with the block number, {block} with the block style - -window.WDiffHtmlCustomize = function(text, block) { - - text = text.replace(/\{number\}/, block); - text = text.replace(/\{block\}/, wDiffStyleBlock[block]); - - return(text); -} - - -// HtmlFormat: replaces newlines and multiple spaces in text with html code - -window.WDiffHtmlFormat = function(text) { - - text = text.replace(/ /g, '  '); - text = text.replace(/\n/g, '
'); - - return(text); -} - - -// WDiffDetectBlocks: detect block borders and moved blocks -// input: text object, block object - -window.WDiffDetectBlocks = function(text, block) { - - block.oldStart = []; - block.oldToNew = []; - block.oldLength = []; - block.oldWords = []; - block.newStart = []; - block.newLength = []; - block.newWords = []; - block.newNumber = []; - block.newBlock = []; - block.newLeft = []; - block.newRight = []; - block.newLeftIndex = []; - block.newRightIndex = []; - - var blockNumber = 0; - var wordCounter = 0; - var realWordCounter = 0; - -// get old text block order - if (wDiffShowBlockMoves) { - var j = 0; - var i = 0; - do { - -// detect block boundaries on old text - if ( (text.oldToNew[j] != i) || (blockNumber == 0 ) ) { - if ( ( (text.oldToNew[j] != null) || (j >= text.oldWords.length) ) && ( (text.newToOld[i] != null) || (i >= text.newWords.length) ) ) { - if (blockNumber > 0) { - block.oldLength[blockNumber - 1] = wordCounter; - block.oldWords[blockNumber - 1] = realWordCounter; - wordCounter = 0; - realWordCounter = 0; - } - - if (j >= text.oldWords.length) { - j ++; - } - else { - i = text.oldToNew[j]; - block.oldStart[blockNumber] = j; - block.oldToNew[blockNumber] = text.oldToNew[j]; - blockNumber ++; - } - } - } - -// jump over identical pairs - while ( (i < text.newWords.length) && (j < text.oldWords.length) ) { - if ( (text.newToOld[i] == null) || (text.oldToNew[j] == null) ) { - break; - } - if (text.oldToNew[j] != i) { - break; - } - i ++; - j ++; - wordCounter ++; - if ( /\w/.test( text.newWords[i] ) ) { - realWordCounter ++; - } - } - -// jump over consecutive deletions - while ( (text.oldToNew[j] == null) && (j < text.oldWords.length) ) { - j ++; - } - -// jump over consecutive inserts - while ( (text.newToOld[i] == null) && (i < text.newWords.length) ) { - i ++; - } - } while (j <= text.oldWords.length); - -// get the block order in the new text - var lastMin; - var currMinIndex; - lastMin = null; - -// sort the data by increasing start numbers into new text block info - for (var i = 0; i < blockNumber; i ++) { - currMin = null; - for (var j = 0; j < blockNumber; j ++) { - curr = block.oldToNew[j]; - if ( (curr > lastMin) || (lastMin == null) ) { - if ( (curr < currMin) || (currMin == null) ) { - currMin = curr; - currMinIndex = j; - } - } - } - block.newStart[i] = block.oldToNew[currMinIndex]; - block.newLength[i] = block.oldLength[currMinIndex]; - block.newWords[i] = block.oldWords[currMinIndex]; - block.newNumber[i] = currMinIndex; - lastMin = currMin; - } - -// detect not moved blocks - for (var i = 0; i < blockNumber; i ++) { - if (block.newBlock[i] == null) { - if (block.newNumber[i] == i) { - block.newBlock[i] = 0; - } - } - } - -// detect switches of neighbouring blocks - for (var i = 0; i < blockNumber - 1; i ++) { - if ( (block.newBlock[i] == null) && (block.newBlock[i + 1] == null) ) { - if (block.newNumber[i] - block.newNumber[i + 1] == 1) { - if ( (block.newNumber[i + 1] - block.newNumber[i + 2] != 1) || (i + 2 >= blockNumber) ) { - -// the shorter one is declared the moved one - if (block.newLength[i] < block.newLength[i + 1]) { - block.newBlock[i] = 1; - block.newBlock[i + 1] = 0; - } - else { - block.newBlock[i] = 0; - block.newBlock[i + 1] = 1; - } - } - } - } - } - -// mark all others as moved and number the moved blocks - j = 1; - for (var i = 0; i < blockNumber; i ++) { - if ( (block.newBlock[i] == null) || (block.newBlock[i] == 1) ) { - block.newBlock[i] = j++; - } - } - -// check if a block has been moved from this block border - for (var i = 0; i < blockNumber; i ++) { - for (var j = 0; j < blockNumber; j ++) { - - if (block.newNumber[j] == i) { - if (block.newBlock[j] > 0) { - -// block moved right - if (block.newNumber[j] < j) { - block.newRight[i] = block.newBlock[j]; - block.newRightIndex[i] = j; - } - -// block moved left - else { - block.newLeft[i + 1] = block.newBlock[j]; - block.newLeftIndex[i + 1] = j; - } - } - } - } - } - } - return; -} - - -// WDiffShortenOutput: remove unchanged parts from final output -// input: the output of WDiffString -// returns: the text with removed unchanged passages indicated by (...) - -window.WDiffShortenOutput = function(diffText) { - -// html
to newlines - diffText = diffText.replace(/]*>/g, '\n'); - -// scan for diff html tags - var regExpDiff = new RegExp('<\\w+ class=\\"(\\w+)\\"[^>]*>(.|\\n)*?', 'g'); - var tagStart = []; - var tagEnd = []; - var i = 0; - var found; - while ( (found = regExpDiff.exec(diffText)) != null ) { - -// combine consecutive diff tags - if ( (i > 0) && (tagEnd[i - 1] == found.index) ) { - tagEnd[i - 1] = found.index + found[0].length; - } - else { - tagStart[i] = found.index; - tagEnd[i] = found.index + found[0].length; - i ++; - } - } - -// no diff tags detected - if (tagStart.length == 0) { - return(wDiffNoChange); - } - -// define regexps - var regExpHeading = new RegExp('\\n=+.+?=+ *\\n|\\n\\{\\||\\n\\|\\}', 'g'); - var regExpParagraph = new RegExp('\\n\\n+', 'g'); - var regExpLine = new RegExp('\\n+', 'g'); - var regExpBlank = new RegExp('(<[^>]+>)*\\s+', 'g'); - -// determine fragment border positions around diff tags - var rangeStart = []; - var rangeEnd = []; - var rangeStartType = []; - var rangeEndType = []; - for (var i = 0; i < tagStart.length; i ++) { - var found; - -// find last heading before diff tag - var lastPos = tagStart[i] - wDiffHeadingBefore; - if (lastPos < 0) { - lastPos = 0; - } - regExpHeading.lastIndex = lastPos; - while ( (found = regExpHeading.exec(diffText)) != null ) { - if (found.index > tagStart[i]) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'heading'; - } - -// find last paragraph before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffParagraphBefore; - if (lastPos < 0) { - lastPos = 0; - } - regExpParagraph.lastIndex = lastPos; - while ( (found = regExpParagraph.exec(diffText)) != null ) { - if (found.index > tagStart[i]) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'paragraph'; - } - } - -// find line break before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffLineBeforeMax; - if (lastPos < 0) { - lastPos = 0; - } - regExpLine.lastIndex = lastPos; - while ( (found = regExpLine.exec(diffText)) != null ) { - if (found.index > tagStart[i] - wDiffLineBeforeMin) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'line'; - } - } - -// find blank before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffBlankBeforeMax; - if (lastPos < 0) { - lastPos = 0; - } - regExpBlank.lastIndex = lastPos; - while ( (found = regExpBlank.exec(diffText)) != null ) { - if (found.index > tagStart[i] - wDiffBlankBeforeMin) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'blank'; - } - } - -// fixed number of chars before diff tag - if (rangeStart[i] == null) { - rangeStart[i] = tagStart[i] - wDiffCharsBefore; - rangeStartType[i] = 'chars'; - if (rangeStart[i] < 0) { - rangeStart[i] = 0; - } - } - -// find first heading after diff tag - regExpHeading.lastIndex = tagEnd[i]; - if ( (found = regExpHeading.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffHeadingAfter) { - rangeEnd[i] = found.index + found[0].length; - rangeEndType[i] = 'heading'; - } - } - -// find first paragraph after diff tag - if (rangeEnd[i] == null) { - regExpParagraph.lastIndex = tagEnd[i]; - if ( (found = regExpParagraph.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffParagraphAfter) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'paragraph'; - } - } - } - -// find first line break after diff tag - if (rangeEnd[i] == null) { - regExpLine.lastIndex = tagEnd[i] + wDiffLineAfterMin; - if ( (found = regExpLine.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffLineAfterMax) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'break'; - } - } - } - -// find blank after diff tag - if (rangeEnd[i] == null) { - regExpBlank.lastIndex = tagEnd[i] + wDiffBlankAfterMin; - if ( (found = regExpBlank.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffBlankAfterMax) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'blank'; - } - } - } - -// fixed number of chars after diff tag - if (rangeEnd[i] == null) { - rangeEnd[i] = tagEnd[i] + wDiffCharsAfter; - if (rangeEnd[i] > diffText.length) { - rangeEnd[i] = diffText.length; - rangeEndType[i] = 'chars'; - } - } - } - -// remove overlaps, join close fragments - var fragmentStart = []; - var fragmentEnd = []; - var fragmentStartType = []; - var fragmentEndType = []; - fragmentStart[0] = rangeStart[0]; - fragmentEnd[0] = rangeEnd[0]; - fragmentStartType[0] = rangeStartType[0]; - fragmentEndType[0] = rangeEndType[0]; - var j = 1; - for (var i = 1; i < rangeStart.length; i ++) { - if (rangeStart[i] > fragmentEnd[j - 1] + wDiffFragmentJoin) { - fragmentStart[j] = rangeStart[i]; - fragmentEnd[j] = rangeEnd[i]; - fragmentStartType[j] = rangeStartType[i]; - fragmentEndType[j] = rangeEndType[i]; - j ++; - } - else { - fragmentEnd[j - 1] = rangeEnd[i]; - fragmentEndType[j - 1] = rangeEndType[i]; - } - } - -// assemble the fragments - var outText = ''; - for (var i = 0; i < fragmentStart.length; i ++) { - -// get text fragment - var fragment = diffText.substring(fragmentStart[i], fragmentEnd[i]); - var fragment = fragment.replace(/^\n+|\n+$/g, ''); - -// add inline marks for omitted chars and words - if (fragmentStart[i] > 0) { - if (fragmentStartType[i] == 'chars') { - fragment = wDiffOmittedChars + fragment; - } - else if (fragmentStartType[i] == 'blank') { - fragment = wDiffOmittedChars + ' ' + fragment; - } - } - if (fragmentEnd[i] < diffText.length) { - if (fragmentStartType[i] == 'chars') { - fragment = fragment + wDiffOmittedChars; - } - else if (fragmentStartType[i] == 'blank') { - fragment = fragment + ' ' + wDiffOmittedChars; - } - } - -// add omitted line separator - if (fragmentStart[i] > 0) { - outText += wDiffOmittedLines; - } - -// encapsulate span errors - outText += '
' + fragment + '
'; - } - -// add trailing omitted line separator - if (fragmentEnd[i - 1] < diffText.length) { - outText = outText + wDiffOmittedLines; - } - -// remove leading and trailing empty lines - outText = outText.replace(/^(
)\n+|\n+(<\/div>)$/g, '$1$2'); - -// convert to html linebreaks - outText = outText.replace(/\n/g, '
'); - - return(outText); -} - - -//

\ No newline at end of file
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 38c9e5a..1bb6cf4 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -511,6 +511,31 @@ table.revisions_table tr:hover {
   background-color: #f1f1f1;
 }
 
+#diffview del {
+  background: #ffd7d5;
+  color: #82071e;
+  text-decoration: line-through;
+  padding: 0 2px;
+  border-radius: 2px;
+}
+
+#diffview ins {
+  background: #ccffd8;
+  color: #055d20;
+  text-decoration: none;
+  padding: 0 2px;
+  border-radius: 2px;
+}
+
+#diffview .diff_side_by_side {
+  display: flex;
+  gap: 1rem;
+}
+
+#diffview .diff_column {
+  flex: 1;
+}
+
 table.user_table td.user_login {
   padding-right: 30px;
 }
diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb
index b4dcd8f..e2fc976 100644
--- a/test/controllers/revisions_controller_test.rb
+++ b/test/controllers/revisions_controller_test.rb
@@ -59,4 +59,33 @@ class RevisionsControllerTest < ActionController::TestCase
     assert_equal @node.head, @node.pages.first
     assert_equal "first", @node.head.reload.body
   end
+
+  test "diffing two revisions renders real markup with only the changed words marked" do
+    login_as :quentin
+    post(
+      :diff, params: {
+        :node_id => @node.id,
+        :start_revision => @node.pages.first.revision,
+        :end_revision => @node.pages.last.revision
+      }
+    )
+    assert_response :success
+    assert_select "del", "first"
+    assert_select "ins", "second"
+    assert_no_match /</, response.body
+  end
+
+  test "diffing two revisions in side by side view renders two columns" do
+    login_as :quentin
+    post(
+      :diff, params: {
+        :node_id => @node.id,
+        :start_revision => @node.pages.first.revision,
+        :end_revision => @node.pages.last.revision,
+        :view => "side_by_side"
+      }
+    )
+    assert_response :success
+    assert_select ".diff_column", 2
+  end
 end
diff --git a/test/models/page_test.rb b/test/models/page_test.rb
index ad2742f..ac5691a 100644
--- a/test/models/page_test.rb
+++ b/test/models/page_test.rb
@@ -176,4 +176,38 @@ class PageTest < ActiveSupport::TestCase
     assert_not_equal first_page.id, first_page.node.head_id
     assert first_page.published_at.present?
   end
+
+  def test_diff_against_inline_keeps_tags_and_marks_only_the_changed_word
+    n = Node.root.children.create! :slug => "diff_against_test"
+    d = n.find_or_create_draft @user1
+    d.title = "Old heading"
+    d.save!
+    n.publish_draft!
+
+    d2 = n.find_or_create_draft @user1
+    d2.title = "New heading"
+    d2.save!
+
+    diff = d2.diff_against(n.head)
+
+    assert_match "Old", diff[:title]
+    assert_match "New", diff[:title]
+  end
+
+  def test_diff_against_side_by_side_returns_two_annotated_strings
+    n = Node.root.children.create! :slug => "diff_against_sbs_test"
+    d = n.find_or_create_draft @user1
+    d.title = "Old heading"
+    d.save!
+    n.publish_draft!
+
+    d2 = n.find_or_create_draft @user1
+    d2.title = "New heading"
+    d2.save!
+
+    old_html, new_html = d2.diff_against(n.head, view: :side_by_side)[:title]
+
+    assert_match "Old", old_html
+    assert_match "New", new_html
+  end
 end
-- 
cgit v1.3


From 205e6216fc7850fe717122c189e5003d1f9e8afe Mon Sep 17 00:00:00 2001
From: erdgeist 
Date: Fri, 10 Jul 2026 02:03:19 +0200
Subject: Add head/draft/autosave layer comparison at three UI entry points

Node#resolve_page_reference and #available_layer_pairs let
Page#diff_against compare named layers (head/draft/autosave), not
just numbered revisions -- autosave was never part of Node#pages, so
this was the missing piece.

Wired into nodes#show's Status section, nodes#edit right after an
autosave gets resurrected ("What changed?"), and the admin wizard's
current-drafts table, which now also lists autosave-only nodes it
previously never showed.

revisions#diff hides the numbered-revision picker when comparing
named layers (it can't represent them), shows a plain label instead,
and offers buttons to switch between whichever other pairs make
sense for the node's current state. Destroying the topmost layer is
available directly from the diff view, reusing the existing revert!
path.

"Discard changes" is renamed "Discard Autosave" everywhere it
appears, to match "Destroy Draft".
---
 app/controllers/admin_controller.rb           |  4 +--
 app/controllers/nodes_controller.rb           |  2 +-
 app/controllers/revisions_controller.rb       | 16 ++++++++---
 app/helpers/revisions_helper.rb               |  5 ++++
 app/models/node.rb                            | 21 ++++++++++++++
 app/views/admin/index.html.erb                | 13 +++++++--
 app/views/nodes/edit.html.erb                 |  9 +++++-
 app/views/nodes/show.html.erb                 | 12 +++++++-
 app/views/revisions/diff.html.erb             | 40 +++++++++++++++++++++++----
 config/routes.rb                              |  1 +
 test/controllers/admin_controller_test.rb     | 15 ++++++++--
 test/controllers/revisions_controller_test.rb | 39 ++++++++++++++++++++++++++
 test/models/node_test.rb                      | 22 +++++++++++++++
 13 files changed, 179 insertions(+), 20 deletions(-)

(limited to 'app/controllers')

diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 3fa0519..6ab2135 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -5,10 +5,10 @@ class AdminController < ApplicationController
   before_action :login_required
 
   def index
-    @drafts = Node.where("draft_id IS NOT NULL")
+    @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").count
+    @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",
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 38d42d9..d1538e1 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -72,7 +72,7 @@ class NodesController < ApplicationController
     if @node.autosave
       flash.now[:notice] =
         "This page has unsaved changes from a previous session, shown below. " \
-        "Save to keep them, or use \"Discard changes\" below to go back to the last saved version."
+        "Save to keep them, or use \"Discard Autosave\" below to go back to the last saved version."
     elsif freshly_locked
       flash.now[:notice] = "Node locked and ready to edit"
     end
diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb
index 9acb26f..4b0c549 100644
--- a/app/controllers/revisions_controller.rb
+++ b/app/controllers/revisions_controller.rb
@@ -21,10 +21,18 @@ class RevisionsController < ApplicationController
       params[:start_revision], params[:end_revision] = 1, 1
     end
 
-    @start      = @node.pages.find_by_revision( params[:start_revision] )
-    @end        = @node.pages.find_by_revision( params[:end_revision] )
-    @diff_view  = params[:view] == "side_by_side" ? :side_by_side : :inline
-    @diff       = @end.diff_against( @start, view: @diff_view )
+    @start = @node.resolve_page_reference(params[:start_revision])
+    @end   = @node.resolve_page_reference(params[:end_revision])
+
+    if @start.nil? || @end.nil?
+      flash[:error] = "That comparison is no longer available."
+      redirect_to(node_path(@node)) and return
+    end
+
+    @diff_view              = params[:view] == "side_by_side" ? :side_by_side : :inline
+    @diff                   = @end.diff_against(@start, view: @diff_view)
+    @available_layer_pairs  = @node.available_layer_pairs
+    @locked_by_other        = @node.locked? && @node.lock_owner != current_user
   end
 
   def show
diff --git a/app/helpers/revisions_helper.rb b/app/helpers/revisions_helper.rb
index fdb51f8..a629013 100644
--- a/app/helpers/revisions_helper.rb
+++ b/app/helpers/revisions_helper.rb
@@ -1,2 +1,7 @@
 module RevisionsHelper
+  # Human-readable label for a diff endpoint -- "head"/"draft"/"autosave"
+  # get their name; anything else is a revision number.
+  def describe_page_reference(ref)
+    %w[head draft autosave].include?(ref.to_s) ? ref.to_s.capitalize : "revision #{ref}"
+  end
 end
diff --git a/app/models/node.rb b/app/models/node.rb
index 7675ab6..82d9954 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -123,6 +123,27 @@ class Node < ApplicationRecord
     self.draft.reload
   end
 
+  def resolve_page_reference ref
+    case ref.to_s
+    when "head" then head
+    when "draft" then draft
+    when "autosave" then autosave
+    else pages.find_by_revision(ref)
+    end
+  end
+
+  # Which layer-pairs are meaningful to compare right now, given this
+  # node's actual state. Head vs autosave only shows up when no draft
+  # sits between them -- with a draft present, autosave is compared
+  # against the draft, never past it straight to head.
+  def available_layer_pairs
+    pairs = []
+    pairs << [:head, :draft]     if head && draft
+    pairs << [:draft, :autosave] if draft && autosave
+    pairs << [:head, :autosave]  if head && autosave && !draft
+    pairs
+  end
+
   def find_or_create_draft current_user
     self.wipe_draft!
     if draft && self.lock_owner == current_user
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index 77b45f4..c67ccb3 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -82,9 +82,9 @@
 
- -

Current Drafts (<%= @drafts_count %>)

- + +

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

+ @@ -92,6 +92,7 @@ + <% @drafts.each do |node| %> "> @@ -103,6 +104,9 @@ + <% end %>
IDActions Locked by Rev.Autosave
<%= 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 %> @@ -110,6 +114,9 @@ <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> + <%= node.autosave ? "unsaved changes" : "" %> +
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index cdc9b36..1c19410 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -6,9 +6,16 @@ disabled: @node.autosave.present? %> <% if @node.autosave || (@node.draft && @node.head) %> - <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% if pair = @node.available_layer_pairs.find { |p| p.include?(:autosave) } %> + <%= button_to 'What changed?', + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last }, + form: { class: 'button_to computation' } %> + <% end %> <% end %> <%= submit_tag "Save Draft", form: dom_id(@node, :edit) %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 036caf2..2469310 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -32,6 +32,16 @@ <% end %>
+ <% @node.available_layer_pairs.each do |pair| %> +
+ <%= button_to "Diff #{pair.first.to_s.capitalize} vs. #{pair.last.to_s.capitalize}", + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last }, + form: { class: 'button_to computation' } %> +
+ <% end %> + <% unless locked_by_other %> <% if @node.draft && !@node.autosave %>
@@ -41,7 +51,7 @@ <% end %> <% if @node.draft || @node.autosave %>
- <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %>
diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d7bb528..3157dca 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -4,11 +4,41 @@ <%= link_to 'Revisions', node_revisions_path(@node) %>

-<%= form_tag diff_node_revisions_path do %> - <%= select_tag :start_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:start_revision].to_i) %> - <%= select_tag :end_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:end_revision].to_i) %> - <%= select_tag :view, options_for_select([['Inline', 'inline'], ['Side by side', 'side_by_side']], @diff_view) %> - <%= submit_tag 'Diff' %> +

+ Comparing <%= describe_page_reference(params[:start_revision]) %> + against <%= describe_page_reference(params[:end_revision]) %> +

+ +<% numeric_comparison = params[:start_revision].to_s =~ /\A\d+\z/ && params[:end_revision].to_s =~ /\A\d+\z/ %> + +<% if numeric_comparison %> + <%= form_tag diff_node_revisions_path do %> + <%= select_tag :start_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:start_revision].to_i) %> + <%= select_tag :end_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:end_revision].to_i) %> + <%= select_tag :view, options_for_select([['Inline', 'inline'], ['Side by side', 'side_by_side']], @diff_view) %> + <%= submit_tag 'Diff' %> + <% end %> +<% else %> +

<%= link_to 'Compare two numbered revisions instead', node_revisions_path(@node) %>

+<% end %> + +<% if @available_layer_pairs.present? %> +

+ <% @available_layer_pairs.each do |pair| %> + <% next if [params[:start_revision].to_s, params[:end_revision].to_s].sort == pair.map(&:to_s).sort %> + <%= button_to "Diff #{pair.first.to_s.capitalize} vs. #{pair.last.to_s.capitalize}", + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last, view: @diff_view }, + form: { class: 'button_to computation' } %> + <% end %> + + <% if !@locked_by_other && (@node.autosave || @node.draft) %> + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), + revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% end %> +

<% end %>
diff --git a/config/routes.rb b/config/routes.rb index c0aef2f..da6b626 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -47,6 +47,7 @@ Cccms::Application.routes.draw do resources :revisions do collection do post :diff + get :diff end member do put :restore diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 9bbf29b..d6005ba 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -1,8 +1,17 @@ require 'test_helper' class AdminControllerTest < ActionController::TestCase - # Replace this with your real tests. - test "the truth" do - assert true + test "current drafts includes nodes with only an autosave" do + node = Node.root.children.create!(:slug => "admin_autosave_only") + node.lock_for_editing!(User.find_by_login("aaron")) + node.autosave!({title: "in progress"}, User.find_by_login("aaron")) + node.save_draft!(User.find_by_login("aaron")) + node.publish_draft! + node.lock_for_editing!(User.find_by_login("aaron")) + node.autosave!({title: "editing again"}, User.find_by_login("aaron")) + + login_as :quentin + get :index + assert_includes assigns(:drafts), node end end diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index caca6bf..162e6f1 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -100,4 +100,43 @@ class RevisionsControllerTest < ActionController::TestCase assert_response :success assert_select ".diff_column", 2 end + + test "diffing head against draft by name" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.draft.update(:body => "draft body") + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + end + + test "diffing a layer pair that no longer exists redirects with a flash" do + login_as :quentin + post(:diff, params: { :node_id => @node.id, :start_revision => "draft", :end_revision => "autosave" }) + assert_redirected_to node_path(@node) + assert flash[:error].present? + end + + test "diffing by name shows a clear comparison label instead of a misleading revision picker" do + login_as :quentin + @node.find_or_create_draft(@user) + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + assert_select "strong", "Head" + assert_select "strong", "Draft" + assert_select "select[name=?]", "start_revision", :count => 0 + end + + test "pair-switcher buttons carry their params as real hidden fields, not a query string" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.lock_for_editing!(@user) + @node.autosave!({ :body => "unsaved" }, @user) + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + assert_select "form.computation input[type=hidden][name=start_revision]" + assert_select "form.computation input[type=hidden][name=end_revision]" + end end diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 2138c19..9e71dec 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -473,4 +473,26 @@ class NodeTest < ActiveSupport::TestCase node.publish_draft! end end + + test "available_layer_pairs matches the six-state table" do + node = Node.root.children.create!(:slug => "layer_pairs_test") + user = @user1 || User.find_by_login("aaron") + + assert_equal [[:draft, :autosave]], (node.lock_for_editing!(user); node.autosave!({title: "v1"}, user); node.available_layer_pairs) # state F + + node.save_draft!(user) + node.publish_draft! + assert_equal [], node.available_layer_pairs # state A + + node.lock_for_editing!(user) + node.autosave!({title: "v2"}, user) + assert_equal [[:head, :autosave]], node.available_layer_pairs # state B + + node.save_draft!(user) + assert_equal [[:head, :draft]], node.available_layer_pairs # state C + + node.lock_for_editing!(user) + node.autosave!({title: "v3"}, user) + assert_equal [[:head, :draft], [:draft, :autosave]], node.available_layer_pairs # state D + end end -- 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 'app/controllers') 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 7d6a665b896252537b8b8df2f15e204d04417231 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 21:57:58 +0200 Subject: Fix current_user/uniq inconsistencies in admin#index, remove dead query @mynodes used the bare @current_user ivar instead of the current_user method (works today only because login_required already forces that memoization; every other query and controller in this app uses the method), and .uniq, which is plain Enumerable#uniq here, not Relation#distinct -- it materializes every joined row into an Array before deduplicating, rather than deduplicating in SQL. @mypages had the same ivar pattern, no .order/.limit unlike every sibling query in this action, and -- confirmed via a full grep -- is referenced nowhere else in the codebase. Removed rather than bounded; nothing was ever rendering it. --- app/controllers/admin_controller.rb | 8 +++----- test/controllers/admin_controller_test.rb | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 6ab2135..435df57 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -20,12 +20,10 @@ class AdminController < ApplicationController ordered_with_level.each { |node, level| @sitemap_depth[node.id] = level } @sitemap = ordered_with_level.map(&:first).reject(&:update?) - @mypages = Page.where("user_id = ? or editor_id = ?", @current_user, @current_user) - @mynodes = Node.joins(:pages) - .where("pages.user_id = ? or pages.editor_id = ?", @current_user, @current_user) - .order("updated_at desc") - .uniq.first(50) + .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) + .order("updated_at desc") + .distinct.first(50) end def conventions diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index d6005ba..13cc1bb 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -14,4 +14,24 @@ class AdminControllerTest < ActionController::TestCase get :index 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 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 'app/controllers') 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 'app/controllers') 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 19e0ee821d5b2b6d3397a81411f4f3a4cbd2fa83 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 13:58:36 +0200 Subject: Unify the four field-level pickers, fix search visibility menu_items/parent_search/move_to_search/event_search each duplicated their own debounce-free AJAX call and result rendering. Replaced with one shared initSearchPicker, taking each picker's distinctive behavior (parent_search's live path preview, event_search's title hint) as a callback rather than a whole reimplementation. Adds a real debounce with an out-of-order-response guard -- none of the four had either before. admin_search (the Alt+F top-bar search) now shares the same function via its own url/isActive/header options, gaining the same guard and fixing an inconsistency of its own (it previously always slid its panel open, even on zero results). Each picker's results container gets its own id instead of sharing was ever supposed to be on screen at once, an assumption with no actual enforcement behind it. Styling moved from an id-pair (#menu_search_results, #search_results) to a shared .search_results class so a future picker never needs this file touched again. menu_search and the admin top-bar search now call Node.editor_search instead of the public, head-only Node.search -- both are admin-only, authenticated views, and had no reason to inherit the public search's "can't find a draft" limitation. The always-ignored :per_page => 1000 on the latter is gone too; Node.search's second argument was never read by the method at all. Also removed a stale #metadata a { text-transform: lowercase } rule, found while verifying the above -- written for the pre-subnav-removal expand-toggle, which no longer exists; it had been silently lowercasing nodes#edit's own, unrelated #metadata div (including move_to_search's results) by id coincidence ever since. #main_navigation and #overview_toggle intentionally left capitalized rather than special-cased -- both belong to the nav bar already slated to shrink to three icons, not worth polishing on the way out. --- app/controllers/admin_controller.rb | 4 +- app/views/events/edit.html.erb | 4 +- app/views/events/new.html.erb | 2 +- app/views/layouts/admin.html.erb | 4 +- app/views/menu_items/edit.html.erb | 2 +- app/views/menu_items/new.html.erb | 4 +- app/views/nodes/edit.html.erb | 14 +- app/views/nodes/new.html.erb | 2 +- public/javascripts/admin_search.js | 355 ++++++++++++------------------------ public/stylesheets/admin.css | 13 +- 10 files changed, 137 insertions(+), 267 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 435df57..3c45c49 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -31,7 +31,7 @@ class AdminController < ApplicationController end def search - @results = Node.search params[:search_term], :per_page => 1000 + @results = Node.editor_search(params[:search_term]) respond_to do |format| format.html do @@ -53,7 +53,7 @@ class AdminController < ApplicationController if params[:search_term] == "Root" @results = [Node.root] else - @results = Node.search params[:search_term] + @results = Node.editor_search(params[:search_term]) end respond_to do |format| diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 6cb04bd..45b084f 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -19,12 +19,12 @@
Change node <%= text_field_tag :event_node_search_term %> -
+
This will re-link the event to a different node — rarely needed.
<% else %> <%= text_field_tag :event_node_search_term %> -
+
Optional — search and pick a node to associate this event with a page. <% end %> <%= f.hidden_field :node_id %> diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb index b20fe48..7a1ee7a 100644 --- a/app/views/events/new.html.erb +++ b/app/views/events/new.html.erb @@ -10,7 +10,7 @@
Node
<%= text_field_tag :event_node_search_term %> -
+
<%= f.hidden_field :node_id %> Optional — search and pick a node to associate this event with a page.
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index e714c28..079346b 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -56,9 +56,7 @@ Search: <%= text_field_tag :search_term, nil, autocomplete: "off" %> <% end %>
- +
diff --git a/app/views/menu_items/edit.html.erb b/app/views/menu_items/edit.html.erb index dc5e8f9..44e5a79 100644 --- a/app/views/menu_items/edit.html.erb +++ b/app/views/menu_items/edit.html.erb @@ -6,7 +6,7 @@
Search
<%= text_field_tag :menu_search_term %> -
+
Node Id
diff --git a/app/views/menu_items/new.html.erb b/app/views/menu_items/new.html.erb index 68081d0..223cb8e 100644 --- a/app/views/menu_items/new.html.erb +++ b/app/views/menu_items/new.html.erb @@ -7,9 +7,7 @@ Search <%= text_field_tag :menu_search_term %> -
- -
+ diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 1c19410..13b78fc 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -45,14 +45,12 @@
parent
<%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> -
- -
- <%= f.hidden_field( - :staged_parent_id, - :value => @node.staged_parent_id || @node.parent_id - ) - %> +
+ <%= f.hidden_field( + :staged_parent_id, + :value => @node.staged_parent_id || @node.parent_id + ) + %>
<%= fields_for @page do |d| %> diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index afc632f..bb7e078 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -34,7 +34,7 @@
<%= text_field_tag :parent_search_term, @parent_name %> <%= hidden_field_tag :parent_id, @parent_id %> -
+
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 0e70845..f553334 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -1,5 +1,4 @@ admin_search = { - initialize : function() { $(document).bind("keydown", 'Alt+f', function(){ admin_search.display_toggle(); @@ -20,25 +19,12 @@ admin_search = { } }); - $("#search_term").bind("input", function() { - if (!$('#search_widget').is(':visible')) return; - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success: function(results) { - admin_search.show_results(results); - }, - error: function(xhr, status, error) { - console.log("Ajax error:", status, error, xhr.status, xhr.responseText); - } - }); - } else { - $('#menu_search_results').slideUp(); - $('#menu_search_results').empty(); - } + initSearchPicker({ + inputSelector: "#search_term", + resultsSelector: "#menu_search_results", + url: ADMIN_SEARCH_URL, + isActive: function() { return $('#search_widget').is(':visible'); }, + resultsHeaderHtml: "

Press Enter to see all results ⏎

" }); }, @@ -49,75 +35,97 @@ admin_search = { $('#search_widget').fadeIn(); $('#search_term').focus(); } - }, - - show_results : function(results) { - $('#menu_search_results').empty(); - if (results.length) { - $('#menu_search_results').append( - "

Press Enter to see all results ⏎

" - ); - } - for (result in results) { - $('#menu_search_results').append( - "

" + - results[result].title + - "" + results[result].unique_name + "" + - "

" - ); - } - $('#menu_search_results').slideDown(); } }; -menu_items = { +function initSearchPicker(options) { + var inputSelector = options.inputSelector; + var resultsSelector = options.resultsSelector; + var url = options.url || ADMIN_MENU_SEARCH_URL; + var onSelect = options.onSelect; // omit for a real-navigation search like admin_search + var isActive = options.isActive; // optional guard, checked before every search + var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist + var requestId = 0; + var timeout; + + $(inputSelector).bind("input", function() { + if (isActive && !isActive()) return; + + var term = $(this).val(); + var results = $(resultsSelector); + clearTimeout(timeout); + + if (!term) { + results.slideUp(); + results.empty(); + return; + } - initialize_search : function() { - $("#menu_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - menu_items.show_results(results); + timeout = setTimeout(function() { + var thisRequest = ++requestId; + $.ajax({ + type: "GET", + url: url, + data: "search_term=" + term, + dataType: "json", + success: function(nodes) { + if (thisRequest !== requestId) return; + results.empty(); + if (nodes.length && resultsHeaderHtml) { + results.append(resultsHeaderHtml); } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + var found = false; + for (var i = 0; i < nodes.length; i++) { + (function(node) { + var link; + if (onSelect) { + link = $( + "

" + node.title + + "" + node.unique_name + "" + + "

" + ); + link.find("a").bind("click", function() { + onSelect(node); + results.slideUp(); + results.empty(); + return false; + }); + } else { + link = $( + "

" + node.title + + "" + node.unique_name + "" + + "

" + ); + } + results.append(link); + found = true; + })(nodes[i]); + } + if (found) { + results.slideDown(); + } else { + results.slideUp(); + } + }, + error: function(xhr, status, error) { + console.log("Ajax error:", status, error, xhr.status, xhr.responseText); + } + }); + }, 250); + }); +} + +menu_items = { + initialize_search : function() { + initSearchPicker({ + inputSelector: "#menu_search_term", + resultsSelector: "#menu_item_search_results", + onSelect: function(node) { + $("#menu_item_node_id").val(node.node_id); + $("#menu_item_path").val("/" + node.unique_name); + $("#menu_item_title").val(node.title); } }); - }, - - show_results : function(results) { - $("#search_results").empty(); - for (result in results) { - var link = $((""+ results[result].title + "")); - $(link).bind("click", menu_items.link_closure(results[result])); - - - // Sometimes I don't get jquery; wrap() didn't work *sigh* - // Guess I'll need a book someday or another framework - var wrapper = $("
"); - $(wrapper).append(link) - - $("#search_results").append(wrapper); - - } - }, - - link_closure : function(node) { - var barf = function(){ - $("#menu_item_node_id").val(node.node_id); - $("#menu_item_path").val("/" + node.unique_name); - $("#menu_item_title").val(node.title); - return false; - } - - return barf; } }; @@ -125,21 +133,13 @@ parent_search = { initialize_search : function() { parent_search.initialize_radio_buttons(); - $("#parent_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - parent_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + initSearchPicker({ + inputSelector: "#parent_search_term", + resultsSelector: "#parent_search_results", + onSelect: function(node) { + $("#parent_search_term").val(node.title); + $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); + parent_search.update_resulting_path(); } }); @@ -154,38 +154,6 @@ parent_search = { }); }, - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - - var link = $(( - "

" + results[result].title + - "" + results[result].unique_name + "" + - "

")); - - $(link).bind("click", parent_search.link_closure(results[result])); - - $("#search_results").append(link); - found = true; - } - if (found) - $('#search_results').slideDown(); - }, - - link_closure : function(node) { - var barf = function(){ - $("#parent_search_term").val(node.title); - $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); - $('#search_results').slideUp(); - $('#search_results').empty(); - parent_search.update_resulting_path(); - return false; - } - - return barf; - }, - update_resulting_path : function() { var kind = $("input[name='kind']:checked"); var title = $("#title").val(); @@ -223,122 +191,35 @@ parent_search = { $("#parent_search_field").hide(); } } -} +}; move_to_search = { initialize_search : function() { - $("#move_to_search_term").bind("input", function() {move_to_search.do_search($(this))}); - }, - - do_search : function(_this) { - if (_this.val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + _this.val(), - dataType: "json", - success : function(results) { - move_to_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); - } - }, - - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - var link = $((""+ results[result].title + "")); - $(link).bind("click", move_to_search.link_closure(results[result])); - - - // Sometimes I don't get jquery; wrap() didn't work *sigh* - // Guess I'll need a book someday or another framework - var wrapper = $("
"); - $(wrapper).append(link) - - $("#search_results").append(wrapper); - found = true; - } - if (found) - $('#search_results').slideDown(); - else - $('#search_results').slideUp(); - - }, - - link_closure : function(node) { - var barf = function(){ - $("#move_to_search_term").val(node.title); - $("#node_staged_parent_id").val(node.node_id); - $('#search_results').slideUp(); - $('#search_results').empty(); - return false; - } - - return barf; + initSearchPicker({ + inputSelector: "#move_to_search_term", + resultsSelector: "#move_to_search_results", + onSelect: function(node) { + $("#move_to_search_term").val(node.title); + $("#node_staged_parent_id").val(node.node_id); + } + }); } -} +}; event_search = { initialize_search : function() { - $("#event_node_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - event_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + initSearchPicker({ + inputSelector: "#event_node_search_term", + resultsSelector: "#event_search_results", + onSelect: function(node) { + $("#event_node_search_term").val(node.title); + $("#event_node_id").val(node.node_id); + + var title_field = $("#event_title"); + if (title_field.val() === "") { + $("#event_title_hint").text("Using \"" + node.title + "\" from the associated node."); + } } }); - }, - - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - var link = $(( - "

" + results[result].title + - "" + results[result].unique_name + "" + - "

")); - - $(link).bind("click", event_search.link_closure(results[result])); - - $("#search_results").append(link); - found = true; - } - if (found) - $('#search_results').slideDown(); - else - $('#search_results').slideUp(); - }, - - link_closure : function(node) { - var barf = function(){ - $("#event_node_search_term").val(node.title); - $("#event_node_id").val(node.node_id); - $('#search_results').slideUp(); - $('#search_results').empty(); - - var title_field = $("#event_title"); - if (title_field.val() === "") { - $("#event_title_hint").text("Using \"" + node.title + "\" from the associated node."); - } - - return false; - } - return barf; } }; diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 5c1e489..da31535 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -155,7 +155,6 @@ input[type=radio] { padding-right: 5px; padding-top: 1px; padding-bottom: 1px; - text-transform: lowercase; } #metadata a:hover { @@ -1023,22 +1022,19 @@ div#draft_list table td.actions a { font-size: 18px; } -#menu_search_results p, -#search_results p { +.search_results p { margin: 0; padding: 4px 4px 0 4px; border-bottom: 1px solid #e8e8e8; } -#menu_search_results p a, -#search_results p a { +.search_results p a { display: block; font-weight: bold; font-size: 0.95rem; } -#menu_search_results p span.result_path, -#search_results p span.result_path { +.search_results p span.result_path { display: block; margin-top: 2px; font-size: 0.75rem; @@ -1047,8 +1043,7 @@ div#draft_list table td.actions a { padding-bottom: 4px; } -#menu_search_results p.search_more, -#search_results p.search_more { +.search_results p.search_more { margin: 0; padding: 6px 4px; font-size: 0.8rem; -- cgit v1.3 From 5803c59192b7fb05840d0b452eb64d9f997f3d8f Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 14:26:30 +0200 Subject: Extract drafts/recent query logic from controller into Node Node.drafts_and_autosaves and Node.recently_changed replace inline query logic in NodesController#drafts/#recent -- pure refactor, no behavior change for either action. The real reason: the dashboard's upcoming abridged widgets need the exact same queries the full pages already use, just limited and (for drafts) sorted with the current user's own locked nodes first. Better to share one method than let a widget and a full page quietly drift onto two versions of "what counts as a current draft." current_user_id: is an explicit, optional argument rather than a separate method -- ordering by lock ownership only applies when a user is given; omitted entirely, it's pure recency, exactly today's behavior. Needed Arel.sql wrapping a sanitized CASE expression before .order would accept it -- sanitize_sql_array only vouches for the values inside the string, a separate Rails safety check still refuses any string shaped like more than a plain column reference unless it's explicitly marked as already-vetted. --- app/controllers/nodes_controller.rb | 9 ++------- app/models/node.rb | 16 ++++++++++++++++ test/models/node_test.rb | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 772bf4b..c1468be 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -185,16 +185,11 @@ class NodesController < ApplicationController # 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) + @nodes = index_matching(Node.drafts_and_autosaves) 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) + @nodes = index_matching(Node.recently_changed) end def mine diff --git a/app/models/node.rb b/app/models/node.rb index 24f3cd0..aa2f7f3 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -367,6 +367,22 @@ class Node < ApplicationRecord .distinct end + def self.drafts_and_autosaves(current_user_id: nil) + scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") + return scope.order("updated_at DESC") unless current_user_id + + scope.order( + Arel.sql(sanitize_sql_array(["CASE WHEN locking_user_id = ? THEN 0 ELSE 1 END, updated_at DESC", current_user_id])) + ) + end + + def self.recently_changed + where( + "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", + Time.now, Time.now - 14.days + ) + end + protected def lock_for! current_user self.lock_owner = current_user diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 15e908b..de540f8 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -537,4 +537,28 @@ class NodeTest < ActiveSupport::TestCase assert_includes Node.editor_search("Backspace Spiegelgraben"), node assert_equal 0, Node.editor_search("Backspace Nonexistentstreet").count end + + test "drafts_and_autosaves without a user sorts by recency only" do + older = Node.root.children.create!(:slug => "drafts_order_older") + older.find_or_create_draft(@user1) + newer = Node.root.children.create!(:slug => "drafts_order_newer") + newer.find_or_create_draft(@user1) + + result = Node.drafts_and_autosaves.to_a + assert result.index(newer) < result.index(older) + end + + test "drafts_and_autosaves with a user puts their own locked nodes first, regardless of recency" do + mine = Node.root.children.create!(:slug => "drafts_order_mine") + mine.lock_for_editing!(@user1) + mine.autosave!({:title => "mine"}, @user1) + + someone_elses_newer = Node.root.children.create!(:slug => "drafts_order_theirs") + other_user = User.find_by_login("quentin") + someone_elses_newer.lock_for_editing!(other_user) + someone_elses_newer.autosave!({:title => "theirs"}, other_user) + + result = Node.drafts_and_autosaves(current_user_id: @user1.id).to_a + assert result.index(mine) < result.index(someone_elses_newer) + end end -- cgit v1.3 From 4ec70e5ecf792a56e868538738d6c7f63bb6cf24 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 16:33:12 +0200 Subject: Add a grouped tag+node search endpoint for the upcoming dashboard AdminController#dashboard_search returns tags and nodes as separate, labeled groups (via ActsAsTaggableOn::Tag.named_like and Node.editor_search) rather than the flat per-node list every existing picker returns. initSearchPicker gains a renderResults callback option that, when given, replaces the default per-node rendering entirely -- lets the dashboard render its two labeled groups without a second, parallel picker implementation. resultsHeaderHtml (the "Press Enter to see all results" hint) is now threaded through as a third argument to renderResults, so a picker with custom rendering can still show it -- previously only the default per-node branch ever did. Tags link straight to the existing /admin/nodes/tags/:tag view rather than becoming an in-place filter chip. --- app/controllers/admin_controller.rb | 18 +++++ app/views/layouts/admin.html.erb | 1 + config/routes.rb | 9 +-- public/javascripts/admin_interface.js | 4 ++ public/javascripts/admin_search.js | 109 +++++++++++++++++++++--------- public/stylesheets/admin.css | 10 +++ test/controllers/admin_controller_test.rb | 24 +++++++ 7 files changed, 139 insertions(+), 36 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 3c45c49..8445997 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -49,6 +49,24 @@ class AdminController < ApplicationController end end + def dashboard_search + term = params[:search_term] + + if term.blank? + render json: { tags: [], nodes: [] } + return + end + + render json: { + tags: ActsAsTaggableOn::Tag.named_like(term).limit(5).map { |tag| + { name: tag.name, tag_path: tags_nodes_path(tags: tag.name) } + }, + nodes: Node.editor_search(term).limit(10).map { |node| + { node_id: node.id, title: node.title, unique_name: node.unique_name, node_path: node_path(node) } + } + } + end + def menu_search if params[:search_term] == "Root" @results = [Node.root] diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 079346b..00d7075 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -17,6 +17,7 @@ var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; + var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; diff --git a/config/routes.rb b/config/routes.rb index bb34bd8..92301e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,10 +66,11 @@ Cccms::Application.routes.draw do end end - match '' => 'admin#index', :as => :admin, :via => :get - match 'search' => 'admin#search', :as => :admin_search, :via => :get - match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get - match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get + match '' => 'admin#index', :as => :admin, :via => :get + match 'search' => 'admin#search', :as => :admin_search, :via => :get + match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get + match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get + match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get end match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 8ca5b5e..3f6a0a9 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -31,6 +31,10 @@ $(document).ready(function () { } }); + if ($("#dashboard_search_term").length != 0) { + dashboard_search.initialize(); + } + if ($("#menu_search_term").length != 0) { menu_items.initialize_search(); } diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index f553334..53bcb5e 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -42,9 +42,10 @@ function initSearchPicker(options) { var inputSelector = options.inputSelector; var resultsSelector = options.resultsSelector; var url = options.url || ADMIN_MENU_SEARCH_URL; - var onSelect = options.onSelect; // omit for a real-navigation search like admin_search - var isActive = options.isActive; // optional guard, checked before every search + var onSelect = options.onSelect; // omit for a real-navigation search like admin_search + var isActive = options.isActive; // optional guard, checked before every search var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist + var renderResults = options.renderResults; // optional, replaces the default per-node rendering entirely var requestId = 0; var timeout; @@ -68,39 +69,44 @@ function initSearchPicker(options) { url: url, data: "search_term=" + term, dataType: "json", - success: function(nodes) { + success: function(data) { if (thisRequest !== requestId) return; results.empty(); - if (nodes.length && resultsHeaderHtml) { - results.append(resultsHeaderHtml); - } - var found = false; - for (var i = 0; i < nodes.length; i++) { - (function(node) { - var link; - if (onSelect) { - link = $( - "

" + node.title + - "" + node.unique_name + "" + - "

" - ); - link.find("a").bind("click", function() { - onSelect(node); - results.slideUp(); - results.empty(); - return false; - }); - } else { - link = $( - "

" + node.title + - "" + node.unique_name + "" + - "

" - ); - } - results.append(link); - found = true; - })(nodes[i]); + + var found; + if (renderResults) { + found = renderResults(data, results, resultsHeaderHtml); + } else { + if (data.length && resultsHeaderHtml) { + results.append(resultsHeaderHtml); + } + found = false; + for (var i = 0; i < data.length; i++) { + (function(node) { + var link; + if (onSelect) { + link = $( + "

" + node.title + + "" + node.unique_name + "

" + ); + link.find("a").bind("click", function() { + onSelect(node); + results.slideUp(); + results.empty(); + return false; + }); + } else { + link = $( + "

" + node.title + + "" + node.unique_name + "

" + ); + } + results.append(link); + found = true; + })(data[i]); + } } + if (found) { results.slideDown(); } else { @@ -115,6 +121,45 @@ function initSearchPicker(options) { }); } +dashboard_search = { + initialize : function() { + initSearchPicker({ + inputSelector: "#dashboard_search_term", + resultsSelector: "#dashboard_search_results", + url: DASHBOARD_SEARCH_URL, + resultsHeaderHtml: "

Press Enter to see all results ⏎

", + renderResults: function(data, results, resultsHeaderHtml) { + var found = false; + + if ((data.tags.length || data.nodes.length) && resultsHeaderHtml) { + results.append(resultsHeaderHtml); + } + + if (data.tags.length) { + results.append("

Tags

"); + data.tags.forEach(function(tag) { + results.append("

" + tag.name + "

"); + found = true; + }); + } + + if (data.nodes.length) { + results.append("

Pages

"); + data.nodes.forEach(function(node) { + results.append( + "

" + node.title + + "" + node.unique_name + "

" + ); + found = true; + }); + } + + return found; + } + }); + } +}; + menu_items = { initialize_search : function() { initSearchPicker({ diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index da31535..ade3a62 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -1052,6 +1052,16 @@ div#draft_list table td.actions a { border-bottom: none; } +.search_results p.search_group_label { + margin: 8px 0 2px; + padding: 0; + border-bottom: none; + font-size: 0.75rem; + color: #969696; + text-transform: lowercase; + font-weight: normal; +} + /* ============================================================ Menu items (navigation editor) ============================================================ */ diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 13cc1bb..9beaf58 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -34,4 +34,28 @@ class AdminControllerTest < ActionController::TestCase 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")) + node.draft.update(:title => "Biometrics Workshop") + node.draft.tag_list = "biometrics-workshop" + node.draft.save! + + login_as :quentin + get :dashboard_search, params: { :search_term => "biometr" }, :format => :json + + json = JSON.parse(response.body) + assert json["tags"].any? { |t| t["name"] == "biometrics-workshop" } + assert json["nodes"].any? { |n| n["title"] == "Biometrics Workshop" } + end + + test "dashboard_search returns empty results for a blank term" do + login_as :quentin + get :dashboard_search, params: { :search_term => "" }, :format => :json + + json = JSON.parse(response.body) + assert_equal [], json["tags"] + assert_equal [], json["nodes"] + 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 'app/controllers') 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 From 70653b681d10917b77dced08f577446ced7568f1 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 03:22:37 +0200 Subject: Add RelatedAssetsController: search, attach, detach, reorder Backend for the asset-picker rebuild -- replaces the plan to dump every image asset in the system into a hidden, unfiltered browse panel on every node edit (the actual current behavior, confirmed by reading nodes/edit.html.erb directly) with a small, name-scoped search endpoint plus create/destroy/update for attach, detach, and reorder. No schema change needed -- RelatedAsset already had everything this requires (asset_id, page_id, an acts_as_list position). search excludes assets already attached to the page, keeping results meaningfully small given hundreds of assets total but only a handful per node in practice. create is find_or_create_by! rather than a bare create!, guarding against the same asset being attached twice from two separate search results. update leans on acts_as_list's own insert_at rather than custom position-shifting logic. Node#editable_page (autosave || draft || head) is extracted since this is now its third call site with identical logic -- deliberately not touching nodes#show, which resolves draft || head without autosave on purpose, a different and correct semantic for "current committed state" versus "what's actively being edited." --- app/controllers/related_assets_controller.rb | 50 +++++++++++++ app/models/node.rb | 4 ++ config/routes.rb | 6 ++ test/controllers/related_assets_controller_test.rb | 83 ++++++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 app/controllers/related_assets_controller.rb create mode 100644 test/controllers/related_assets_controller_test.rb (limited to 'app/controllers') diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb new file mode 100644 index 0000000..906c02c --- /dev/null +++ b/app/controllers/related_assets_controller.rb @@ -0,0 +1,50 @@ +class RelatedAssetsController < ApplicationController + before_action :login_required + before_action :find_node + + def search + term = params[:q].to_s.strip + if term.blank? + render json: [] + return + end + + attached_ids = @node.editable_page.related_assets.pluck(:asset_id) + results = Asset.images + .where("name ILIKE ?", "%#{term}%") + .where.not(id: attached_ids) + .limit(10) + + render json: results.map { |a| + { id: a.id, name: a.name, thumb_url: a.upload.url(:thumb) } + } + end + + def create + asset = Asset.find(params[:asset_id]) + related = @node.editable_page.related_assets.find_or_create_by!(asset: asset) + + render json: { + id: related.id, + asset_id: asset.id, + name: asset.name, + thumb_url: asset.upload.url(:thumb) + } + end + + def destroy + @node.editable_page.related_assets.find(params[:id]).destroy + head :ok + end + + def update + @node.editable_page.related_assets.find(params[:id]).insert_at(params[:position].to_i) + head :ok + end + + private + + def find_node + @node = Node.find(params[:node_id]) + end +end diff --git a/app/models/node.rb b/app/models/node.rb index 1d0a089..0361c1e 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -328,6 +328,10 @@ class Node < ApplicationRecord unique_path.length == 3 && unique_path[0] == "updates" end + def editable_page + autosave || draft || head + end + # Returns immutable node id for all new nodes so that the atom feed entry ids # stay the same eventhough the slug or positions changes. # Can be removed after a year or so ;) diff --git a/config/routes.rb b/config/routes.rb index 92301e5..5d61bae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,6 +55,12 @@ Cccms::Application.routes.draw do put :revert end + resources :related_assets, only: [:create, :destroy, :update] do + collection do + get :search + end + end + resources :revisions do collection do post :diff diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb new file mode 100644 index 0000000..a3082c2 --- /dev/null +++ b/test/controllers/related_assets_controller_test.rb @@ -0,0 +1,83 @@ +require 'test_helper' + +class RelatedAssetsControllerTest < ActionController::TestCase + test "search finds assets by name, excluding ones already attached" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_search_test") + + attached = Asset.create!(:name => "biometrics-scan", :upload_content_type => "image/png") + node.draft.assets << attached + + Asset.create!(:name => "biometrics-poster", :upload_content_type => "image/png") + Asset.create!(:name => "chaostreff-flyer", :upload_content_type => "image/png") + + get :search, params: { :node_id => node.id, :q => "biometrics" } + + json = JSON.parse(response.body) + names = json.map { |a| a["name"] } + assert_includes names, "biometrics-poster" + assert_not_includes names, "biometrics-scan" + assert_not_includes names, "chaostreff-flyer" + end + + test "search returns an empty list for a blank term" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_blank_search_test") + + get :search, params: { :node_id => node.id, :q => "" } + + assert_equal [], JSON.parse(response.body) + end + + test "create attaches an asset to the node's editable page" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_create_test") + asset = Asset.create!(:name => "erfa-photo", :upload_content_type => "image/png") + + post :create, params: { :node_id => node.id, :asset_id => asset.id } + + assert_response :success + assert_includes node.draft.reload.related_assets.map(&:asset_id), asset.id + end + + test "create does not duplicate an already-attached asset" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_dup_test") + asset = Asset.create!(:name => "erfa-photo-2", :upload_content_type => "image/png") + node.draft.assets << asset + + post :create, params: { :node_id => node.id, :asset_id => asset.id } + + assert_response :success + assert_equal 1, node.draft.reload.related_assets.count + end + + test "destroy removes the attached asset" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_destroy_test") + asset = Asset.create!(:name => "old-photo", :upload_content_type => "image/png") + node.draft.assets << asset + related = node.draft.related_assets.first + + delete :destroy, params: { :node_id => node.id, :id => related.id } + + assert_response :success + assert_equal 0, node.draft.reload.related_assets.count + end + + test "update reorders the attached assets" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_reorder_test") + first = Asset.create!(:name => "first-photo", :upload_content_type => "image/png") + second = Asset.create!(:name => "second-photo", :upload_content_type => "image/png") + node.draft.assets << first + node.draft.assets << second + + second_related = node.draft.related_assets.find_by(:asset_id => second.id) + patch :update, params: { :node_id => node.id, :id => second_related.id, :position => 1 } + + assert_response :success + ordered_asset_ids = node.draft.reload.related_assets.map(&:asset_id) + assert_equal [second.id, first.id], ordered_asset_ids + end +end -- cgit v1.3 From 5f56fa867f073d8d1aecbfbba4a340ea414cfb0d Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 03:48:28 +0200 Subject: Add loadOnFocus to initSearchPicker; asset search shows recent by default initSearchPicker's input handler has always short-circuited on an empty term -- correct for every existing picker, none of which want results appearing before anything's typed. The asset picker does want exactly that (show the last few uploaded images without requiring a search first), so this adds an opt-in loadOnFocus option instead of changing the shared default: it fires once when the input gains focus with no term yet, reusing the same request/render path a real search uses rather than a separate code path. The AJAX call itself was pulled out into a named runSearch function so both triggers could share it without duplicating the success/render logic. RelatedAssetsController#search now treats a blank term as "show the most recently created, unattached images" (limit 5) rather than returning nothing, matching the new client behavior. A real search term still returns up to 10 name-matched results, unchanged. --- app/controllers/related_assets_controller.rb | 18 ++- public/javascripts/admin_search.js | 133 +++++++++++---------- test/controllers/related_assets_controller_test.rb | 16 ++- 3 files changed, 91 insertions(+), 76 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb index 906c02c..ae37d2f 100644 --- a/app/controllers/related_assets_controller.rb +++ b/app/controllers/related_assets_controller.rb @@ -3,17 +3,15 @@ class RelatedAssetsController < ApplicationController before_action :find_node def search - term = params[:q].to_s.strip - if term.blank? - render json: [] - return - end - + term = params[:search_term].to_s.strip attached_ids = @node.editable_page.related_assets.pluck(:asset_id) - results = Asset.images - .where("name ILIKE ?", "%#{term}%") - .where.not(id: attached_ids) - .limit(10) + scope = Asset.images.where.not(id: attached_ids) + + results = if term.present? + scope.where("name ILIKE ?", "%#{term}%").limit(10) + else + scope.order(created_at: :desc).limit(5) + end render json: results.map { |a| { id: a.id, name: a.name, thumb_url: a.upload.url(:thumb) } diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 0c768a3..7604bb7 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -42,18 +42,76 @@ function initSearchPicker(options) { var inputSelector = options.inputSelector; var resultsSelector = options.resultsSelector; var url = options.url || ADMIN_MENU_SEARCH_URL; - var onSelect = options.onSelect; // omit for a real-navigation search like admin_search - var isActive = options.isActive; // optional guard, checked before every search - var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist - var renderResults = options.renderResults; // optional, replaces the default per-node rendering entirely + var onSelect = options.onSelect; + var isActive = options.isActive; + var resultsHeaderHtml = options.resultsHeaderHtml; + var renderResults = options.renderResults; + var loadOnFocus = options.loadOnFocus; // optional, fires an initial search on focus with no term typed yet var requestId = 0; var timeout; + var results = $(resultsSelector); + + function runSearch(term) { + var thisRequest = ++requestId; + $.ajax({ + type: "GET", + url: url, + data: "search_term=" + term, + dataType: "json", + success: function(data) { + if (thisRequest !== requestId) return; + results.empty(); + + var found; + if (renderResults) { + found = renderResults(data, results, resultsHeaderHtml); + } else { + if (data.length && resultsHeaderHtml) { + results.append(resultsHeaderHtml); + } + found = false; + for (var i = 0; i < data.length; i++) { + (function(node) { + var link; + if (onSelect) { + link = $( + "

    " + node.title + + "" + node.unique_name + "

    " + ); + link.find("a").bind("click", function() { + onSelect(node); + results.slideUp(); + results.empty(); + return false; + }); + } else { + link = $( + "

    " + node.title + + "" + node.unique_name + "

    " + ); + } + results.append(link); + found = true; + })(data[i]); + } + } + + if (found) { + results.slideDown(); + } else { + results.slideUp(); + } + }, + error: function(xhr, status, error) { + console.log("Ajax error:", status, error, xhr.status, xhr.responseText); + } + }); + } $(inputSelector).bind("input", function() { if (isActive && !isActive()) return; var term = $(this).val(); - var results = $(resultsSelector); clearTimeout(timeout); if (!term) { @@ -62,63 +120,16 @@ function initSearchPicker(options) { return; } - timeout = setTimeout(function() { - var thisRequest = ++requestId; - $.ajax({ - type: "GET", - url: url, - data: "search_term=" + term, - dataType: "json", - success: function(data) { - if (thisRequest !== requestId) return; - results.empty(); - - var found; - if (renderResults) { - found = renderResults(data, results, resultsHeaderHtml); - } else { - if (data.length && resultsHeaderHtml) { - results.append(resultsHeaderHtml); - } - found = false; - for (var i = 0; i < data.length; i++) { - (function(node) { - var link; - if (onSelect) { - link = $( - "

    " + node.title + - "" + node.unique_name + "

    " - ); - link.find("a").bind("click", function() { - onSelect(node); - results.slideUp(); - results.empty(); - return false; - }); - } else { - link = $( - "

    " + node.title + - "" + node.unique_name + "

    " - ); - } - results.append(link); - found = true; - })(data[i]); - } - } - - if (found) { - results.slideDown(); - } else { - results.slideUp(); - } - }, - error: function(xhr, status, error) { - console.log("Ajax error:", status, error, xhr.status, xhr.responseText); - } - }); - }, 250); + timeout = setTimeout(function() { runSearch(term); }, 250); }); + + if (loadOnFocus) { + $(inputSelector).bind("focus", function() { + if (isActive && !isActive()) return; + if ($(this).val()) return; // the input handler above already covers a real term + runSearch(""); + }); + } } dashboard_search = { diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb index a3082c2..7f6d487 100644 --- a/test/controllers/related_assets_controller_test.rb +++ b/test/controllers/related_assets_controller_test.rb @@ -11,7 +11,7 @@ class RelatedAssetsControllerTest < ActionController::TestCase Asset.create!(:name => "biometrics-poster", :upload_content_type => "image/png") Asset.create!(:name => "chaostreff-flyer", :upload_content_type => "image/png") - get :search, params: { :node_id => node.id, :q => "biometrics" } + get :search, params: { :node_id => node.id, :search_term => "biometrics" } json = JSON.parse(response.body) names = json.map { |a| a["name"] } @@ -20,13 +20,19 @@ class RelatedAssetsControllerTest < ActionController::TestCase assert_not_includes names, "chaostreff-flyer" end - test "search returns an empty list for a blank term" do + test "search with a blank term returns the most recently created assets" do + Asset.delete_all + RelatedAsset.delete_all login_as :quentin - node = Node.root.children.create!(:slug => "related_assets_blank_search_test") + node = Node.root.children.create!(:slug => "related_assets_recent_test") - get :search, params: { :node_id => node.id, :q => "" } + Asset.create!(:name => "older-photo", :upload_content_type => "image/png", :created_at => 2.days.ago) + Asset.create!(:name => "newer-photo", :upload_content_type => "image/png", :created_at => 1.hour.ago) - assert_equal [], JSON.parse(response.body) + get :search, params: { :node_id => node.id, :search_term => "" } + + json = JSON.parse(response.body) + assert_equal ["newer-photo", "older-photo"], json.map { |a| a["name"] } end test "create attaches an asset to the node's editable page" do -- cgit v1.3 From 8baac265059b70da0148487458ee4077b15f155e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 04:54:33 +0200 Subject: Asset picker: attach/detach/reorder UI, read-only view, autosave fix Replaces nodes#edit's old Images section -- a hidden panel dumping every image asset in the system unfiltered (#image_browser) plus a raw drag-and-drop box (#image_box) -- with a small search-and-click picker built on the endpoint from the last two commits. Attaching posts immediately and appends the new thumbnail via a cloned