From 4dd49b1eebb0a99d3aee66b7eca539c87a9c6332 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 28 Jun 2026 04:43:28 +0200 Subject: Phase 2: chapter nodes, aggregate partial, fixes - _chapter.html.erb: new partial for erfa/chaostreff aggregated lists; renders title, location, external_url, sanitized body - content_helper: fix aggregate attr regex to allow hyphens in values (erfa-detail tag was silently dropped); add debug logging (remove) - page.rb: suppress libxml stderr noise in rewrite_links_in_body - db/seeds/chapters.rb: one-shot seed script for erfa and chaostreff chapter nodes under parent nodes 548/549; creates bilingual pages, external_url, primary events with RRULEs where known Note: run Node.rebuild!(false) after execution to fix lft/rgt values --- app/views/custom/partials/_chapter.html.erb | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/views/custom/partials/_chapter.html.erb (limited to 'app/views') diff --git a/app/views/custom/partials/_chapter.html.erb b/app/views/custom/partials/_chapter.html.erb new file mode 100644 index 0000000..a5b8662 --- /dev/null +++ b/app/views/custom/partials/_chapter.html.erb @@ -0,0 +1,10 @@ +
+

<%= link_to_path page.title, page.node.unique_name %>

+ <% if page.abstract.present? %> +
<%= page.abstract %>
+ <% end %> + <% if page.node.external_url.present? %> +
<%= link_to page.node.external_url, page.node.external_url, target: '_blank', rel: 'noopener' %>
+ <% end %> +

<%= sanitize page.body %>

+
-- cgit v1.3 From 4705ef970469a852c7bdb4c097ba748e972c8f63 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 29 Jun 2026 16:26:31 +0200 Subject: Add chapter_detail template with events and external URL rendering New public page template for erfa and chaostreff nodes. Renders the node's external_url and lists associated events below the page content. Seed script updated to set template_name: 'chapter_detail' on all chapter drafts so the template is applied on publish. --- .../page_templates/public/chapter_detail.html.erb | 23 ++++++++++++++++++++++ db/seeds/chapters.rb | 1 + 2 files changed, 24 insertions(+) create mode 100644 app/views/custom/page_templates/public/chapter_detail.html.erb (limited to 'app/views') diff --git a/app/views/custom/page_templates/public/chapter_detail.html.erb b/app/views/custom/page_templates/public/chapter_detail.html.erb new file mode 100644 index 0000000..71352d3 --- /dev/null +++ b/app/views/custom/page_templates/public/chapter_detail.html.erb @@ -0,0 +1,23 @@ +
+

<%= @page.title %>

+ <% if @page.node.external_url.present? %> +
<%= link_to @page.node.external_url, @page.node.external_url, target: '_blank', rel: 'noopener' %>
+ <% end %> +

<%= sanitize(@page.abstract) %>

+ <%= aggregate?(@page.body) %> + <% if @page.node.events.any? %> +
+

Events

+
    + <% @page.node.events.order(:start_time).each do |event| %> +
  • + <%= event.display_title %> + <% if event.rrule.present? %> + <%= event.rrule %> + <% end %> +
  • + <% end %> +
+
+ <% end %> +
diff --git a/db/seeds/chapters.rb b/db/seeds/chapters.rb index cb48894..8584acd 100644 --- a/db/seeds/chapters.rb +++ b/db/seeds/chapters.rb @@ -34,6 +34,7 @@ def seed_chapter(parent_id:, slug:, tag:, title_de:, description_de:, # Set up draft with German translation draft = node.draft + draft.template_name = 'chapter_detail' I18n.with_locale(:de) do draft.title = title_de draft.abstract = location || "" -- cgit v1.3 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 +- app/helpers/nodes_helper.rb | 20 +++++++++----------- app/models/occurrence.rb | 2 +- app/models/page.rb | 8 ++++---- app/views/admin/_menu.html.erb | 1 + app/views/events/index.html.erb | 8 ++++---- 6 files changed, 20 insertions(+), 21 deletions(-) (limited to 'app/views') 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 diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index d88d12a..4293628 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -30,19 +30,17 @@ module NodesHelper end def event_information - if @node.events.first - event = @node.events.first + events = @node.events.order(:start_time) + items = events.map do |event| safe_join([ - "#{event.start_time.to_fs(:db)} - #{event.end_time.to_fs(:db)} > ", - link_to('show', event_path(event)), - ' > ', - link_to('edit', edit_event_path(event)) - ]) - else - safe_join([ - 'no event attached > ', - link_to('add', new_event_path(:node_id => @node.id)) + "#{event.start_time&.to_fs(:db)} - #{event.end_time&.to_fs(:db)} > ", + link_to('edit', edit_event_path(event)), ]) end + safe_join([ + safe_join(items, ' | '), + ' > ', + link_to('add event', new_event_path(:node_id => @node.id)) + ]) end end diff --git a/app/models/occurrence.rb b/app/models/occurrence.rb index 143124f..777be24 100644 --- a/app/models/occurrence.rb +++ b/app/models/occurrence.rb @@ -35,7 +35,7 @@ class Occurrence < ApplicationRecord self.create( :start_time => occurrence, :end_time => (occurrence + duration), - :node_id => node.id, + :node_id => node&.id, :event_id => event.id ) end diff --git a/app/models/page.rb b/app/models/page.rb index 385b3f6..c982c2e 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -63,15 +63,15 @@ class Page < ApplicationRecord end end + direction = %w[ASC DESC].include?(options[:order_direction]&.upcase) ? options[:order_direction].upcase : "ASC" + if options[:order_by] == "title" return scope - .joins(:translations) - .where(page_translations: { locale: I18n.locale }) - .order("page_translations.title #{options[:order_direction]}") + .order(Arel.sql("(SELECT pt.title FROM page_translations pt WHERE pt.page_id = pages.id AND pt.locale = #{ActiveRecord::Base.connection.quote(I18n.locale.to_s)}) #{direction}")) .paginate(:page => page, :per_page => options[:limit]) end - scope.order("#{options[:order_by]} #{options[:order_direction]}") + scope.order("#{options[:order_by]} #{direction}") .paginate(:page => page, :per_page => options[:limit]) end diff --git a/app/views/admin/_menu.html.erb b/app/views/admin/_menu.html.erb index 6dba085..c87c5f7 100644 --- a/app/views/admin/_menu.html.erb +++ b/app/views/admin/_menu.html.erb @@ -4,5 +4,6 @@ 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') %> >  diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 19b21ce..064fa86 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -2,27 +2,27 @@ + + - - <% @events.each do |event| %> + + - - -- cgit v1.3 From 95955abaa339098755a214cfcadf87c90211fe64 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 1 Jul 2026 00:24:10 +0200 Subject: Add RRULE humanizer and wire events into nodes#show - app/models/concerns/rrule_humanizer.rb: new concern included into Event, renders recurring schedule as natural-language German or English from RRULE string; handles WEEKLY/MONTHLY, biweekly (INTERVAL=2), ordinal weekday positions (1TU, -1TH, -2WE), BYMONTH single-month exclusions (December pause convention); gracefully returns nil for COUNT/UNTIL/unrecognized shapes - test/models/concerns/rrule_humanizer_test.rb: 15 tests covering all distinct RRULE shapes found in production data - app/helpers/nodes_helper.rb: add event_schedule_text helper combining humanize_rrule with start_time formatting - app/views/nodes/show.html.erb: add events row, conditionally rendered when node has associated events - config/locales/de.yml, en.yml: add event_schedule_time, event_schedule_unrecognized, event_schedule_none keys --- app/helpers/nodes_helper.rb | 16 ++++++ app/models/concerns/rrule_humanizer.rb | 82 +++++++++++++++++++++++++++ app/models/event.rb | 1 + app/views/nodes/show.html.erb | 14 ++++- config/locales/de.yml | 3 + config/locales/en.yml | 3 + test/models/concerns/rrule_humanizer_test.rb | 84 ++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 app/models/concerns/rrule_humanizer.rb create mode 100644 test/models/concerns/rrule_humanizer_test.rb (limited to 'app/views') diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 4293628..329bcc5 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -43,4 +43,20 @@ module NodesHelper link_to('add event', new_event_path(:node_id => @node.id)) ]) end + + def event_schedule_text(event) + if event.rrule.present? + recurrence = event.humanize_rrule(I18n.locale) + if recurrence + time = event.start_time&.strftime("%H:%M") + time ? "#{recurrence} #{t(:event_schedule_time, time: time)}" : recurrence + else + "#{event.rrule} (#{t(:event_schedule_unrecognized)})" + end + elsif event.start_time + I18n.l(event.start_time, format: :long) + else + t(:event_schedule_none) + end + end end diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb new file mode 100644 index 0000000..6cee711 --- /dev/null +++ b/app/models/concerns/rrule_humanizer.rb @@ -0,0 +1,82 @@ +module RruleHumanizer + extend ActiveSupport::Concern + + WEEKDAY_NAMES = { + de: { "MO"=>"Montag","TU"=>"Dienstag","WE"=>"Mittwoch","TH"=>"Donnerstag","FR"=>"Freitag","SA"=>"Samstag","SU"=>"Sonntag" }, + en: { "MO"=>"Monday","TU"=>"Tuesday","WE"=>"Wednesday","TH"=>"Thursday","FR"=>"Friday","SA"=>"Saturday","SU"=>"Sunday" } + }.freeze + + WEEKDAY_NAMES_ADVERBIAL = { + de: { "MO"=>"montags","TU"=>"dienstags","WE"=>"mittwochs","TH"=>"donnerstags","FR"=>"freitags","SA"=>"samstags","SU"=>"sonntags" } + }.freeze + + ORDINAL_NAMES = { + de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, + en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } + }.freeze + + MONTH_NAMES = { + de: %w[Januar Februar März April Mai Juni Juli August September Oktober November Dezember], + en: %w[January February March April May June July August September October November December] + }.freeze + + def humanize_rrule(locale = I18n.locale) + return nil if rrule.blank? + parts = Hash[rrule.split(";").map { |p| p.split("=", 2) }] + return nil if parts["COUNT"] || parts["UNTIL"] # old one-off data, don't guess + + freq, interval, byday, bymonth = parts["FREQ"], parts["INTERVAL"].to_i, parts["BYDAY"], parts["BYMONTH"] + loc = locale.to_sym + weekdays = WEEKDAY_NAMES[loc] || WEEKDAY_NAMES[:en] + ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] + months = MONTH_NAMES[loc] || MONTH_NAMES[:en] + + days = byday&.split(",")&.map do |d| + if d =~ /^(-?\d+)([A-Z]{2})$/ + "#{ordinals[$1.to_i]} #{weekdays[$2]}" + else + weekdays[d] + end + end + + base = + case loc + when :de + case freq + when "WEEKLY" + if days + if interval == 2 + adverbial = byday.split(",").map { |d| WEEKDAY_NAMES_ADVERBIAL[:de][d] } + "Alle zwei Wochen #{adverbial.join(' und ')}" + else + "Jeden #{days.join(' und ')}" + end + else + interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" + end + when "MONTHLY" + days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" + end + else + case freq + when "WEEKLY" + days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") + when "MONTHLY" + days ? "Every #{days.join(' and ')} of the month" : "Monthly" + end + end + return nil unless base + + if bymonth + included = bymonth.split(",").map(&:to_i) + missing = ((1..12).to_a - included) + if missing.size == 1 + excluded_name = months[missing.first - 1] + base += (loc == :de ? ", außer im #{excluded_name}" : ", except in #{excluded_name}") + end + # more than one missing month: bymonth pattern more complex than we handle, leave base as-is silently + end + + base + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 26c79e4..de82674 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,4 +1,5 @@ class Event < ApplicationRecord + include RruleHumanizer belongs_to :node, optional: true has_many :occurrences diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index de4d8a2..7223219 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -42,6 +42,18 @@ + <% if @node.events.any? %> + + + + + <% end %> @@ -59,4 +71,4 @@
TitleIs primary Start time End time Rrule Custom rrule Allday UrlLatitudeLongitude Node
<%=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.latitude %><%=h event.longitude %> <%=h event.node_id %> <%= link_to 'Show', event %> <%= link_to 'Edit', edit_event_path(event) %>Tagged with: <%= @page.tag_list %>
Events +
    + <% @node.events.order(:start_time).each do |event| %> +
  • <%= event_schedule_text(event) %><%= " (primary)" if event.is_primary? %>
  • + <% end %> +
+
Title <%= sanitize( @page.title ) %>
- \ No newline at end of file + diff --git a/config/locales/de.yml b/config/locales/de.yml index 5f77d79..0b42dd3 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -7,6 +7,9 @@ de: sponsors: Sponsoren show_tag_headline: "Seiten mit dem Tag:" old_ccc_de: das alte ccc.de + event_schedule_time: "um %{time} Uhr" + event_schedule_unrecognized: "Termin-Regel nicht automatisch lesbar" + event_schedule_none: "Kein Termin" date: formats: default: "%d.%m.%Y" diff --git a/config/locales/en.yml b/config/locales/en.yml index 2458d4d..93a0d55 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -8,6 +8,9 @@ en: hello: "Hello world" show_tag_headline: "Pages tagged with:" old_ccc_de: the old ccc.de + event_schedule_time: "at %{time}" + event_schedule_unrecognized: "Schedule not automatically readable" + event_schedule_none: "No schedule" time: formats: diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb new file mode 100644 index 0000000..500dbc7 --- /dev/null +++ b/test/models/concerns/rrule_humanizer_test.rb @@ -0,0 +1,84 @@ +require 'test_helper' + +class RruleHumanizerTest < ActiveSupport::TestCase + def humanize(rrule, locale = :de) + Event.new(rrule: rrule).humanize_rrule(locale) + end + + test "weekly single day" do + assert_equal "Jeden Dienstag", humanize("FREQ=WEEKLY;BYDAY=TU") + assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :en) + end + + test "weekly two days" do + assert_equal "Jeden Mittwoch und Freitag", humanize("FREQ=WEEKLY;BYDAY=WE,FR") + assert_equal "Every Wednesday and Friday", humanize("FREQ=WEEKLY;BYDAY=WE,FR", :en) + end + + test "weekly no byday" do + assert_equal "Wöchentlich", humanize("FREQ=WEEKLY") + assert_equal "Weekly", humanize("FREQ=WEEKLY", :en) + end + + test "biweekly with day" do + assert_equal "Alle zwei Wochen donnerstags", humanize("FREQ=WEEKLY;INTERVAL=2;BYDAY=TH") + assert_equal "Every other Thursday", humanize("FREQ=WEEKLY;INTERVAL=2;BYDAY=TH", :en) + end + + test "biweekly no day" do + assert_equal "Alle zwei Wochen", humanize("FREQ=WEEKLY;INTERVAL=2") + assert_equal "Every other week", humanize("FREQ=WEEKLY;INTERVAL=2", :en) + end + + test "monthly nth weekday" do + assert_equal "Jeden ersten Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=1TU") + assert_equal "Jeden zweiten Freitag im Monat", humanize("FREQ=MONTHLY;BYDAY=2FR") + assert_equal "Jeden dritten Sonntag im Monat", humanize("FREQ=MONTHLY;BYDAY=3SU") + assert_equal "Jeden letzten Mittwoch im Monat", humanize("FREQ=MONTHLY;BYDAY=-1WE") + end + + test "monthly nth weekday english" do + assert_equal "Every first Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=1TU", :en) + assert_equal "Every last Wednesday of the month", humanize("FREQ=MONTHLY;BYDAY=-1WE", :en) + end + + test "monthly second-to-last" do + assert_equal "Jeden vorletzten Donnerstag im Monat", humanize("FREQ=MONTHLY;BYDAY=-2TH") + assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) + end + + test "monthly no byday" do + assert_equal "Monatlich", humanize("FREQ=MONTHLY") + assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) + end + + test "monthly with single excluded month" do + assert_equal "Jeden letzten Donnerstag im Monat, außer im Dezember", + humanize("FREQ=MONTHLY;BYDAY=-1TH;BYMONTH=1,2,3,4,5,6,7,8,9,10,11") + assert_equal "Every last Thursday of the month, except in December", + humanize("FREQ=MONTHLY;BYDAY=-1TH;BYMONTH=1,2,3,4,5,6,7,8,9,10,11", :en) + end + + test "monthly excluding january" do + assert_equal "Jeden zweiten Mittwoch im Monat, außer im Januar", + humanize("FREQ=MONTHLY;BYMONTH=2,3,4,5,6,7,8,9,10,11,12;BYDAY=2WE") + end + + test "blank rrule returns nil" do + assert_nil humanize(nil) + assert_nil humanize("") + end + + test "count and until are not guessed at" do + assert_nil humanize("FREQ=MONTHLY;BYDAY=1WE;COUNT=36") + assert_nil humanize("FREQ=MONTHLY;BYDAY=1WE;UNTIL=20050105T222222Z") + end + + test "unrecognized freq returns nil" do + assert_nil humanize("FREQ=YEARLY;BYMONTH=12") + end + + test "falls back to english for unknown locale" do + assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :fr) + 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/views') 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 @@
    <% @node.events.order(:start_time).each do |event| %> -
  • <%= event_schedule_text(event) %><%= " (primary)" if event.is_primary? %>
  • +
  • + <%= event_schedule_text(event) %> + [<%= link_to 'show', event_path(event, return_to: request.path) %> + | <%= link_to 'edit', edit_event_path(event, return_to: request.path) %>] +
  • <% end %>
-- cgit v1.3 From f7c3bf9e6869331be0c33c0cd24246c592bf0dff Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 00:12:30 +0200 Subject: Rewrite chapter_detail template with humanized event schedule - Split events into open-day tagged events (prominent, via tagged_with) and other events (secondary listing) - Use event_schedule_text helper for humanized recurrence + start time rather than raw rrule string - Section headings via i18n open_days / upcoming_events keys --- .../page_templates/public/chapter_detail.html.erb | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) (limited to 'app/views') diff --git a/app/views/custom/page_templates/public/chapter_detail.html.erb b/app/views/custom/page_templates/public/chapter_detail.html.erb index 71352d3..f47353c 100644 --- a/app/views/custom/page_templates/public/chapter_detail.html.erb +++ b/app/views/custom/page_templates/public/chapter_detail.html.erb @@ -5,16 +5,26 @@ <% end %>

<%= sanitize(@page.abstract) %>

<%= aggregate?(@page.body) %> - <% if @page.node.events.any? %> + <% open_days = @page.node.events.tagged_with('open-day').order(:start_time) %> + <% other_events = @page.node.events.where.not(id: open_days.map(&:id)).order(:start_time) %> + <% if open_days.any? %> +
+

<%= t(:open_days) %>

+
    + <% open_days.each do |event| %> +
  • <%= event_schedule_text(event) %>
  • + <% end %> +
+
+ <% end %> + <% if other_events.any? %>
-

Events

+

<%= t(:upcoming_events) %>

    - <% @page.node.events.order(:start_time).each do |event| %> + <% other_events.each do |event| %>
  • <%= event.display_title %> - <% if event.rrule.present? %> - <%= event.rrule %> - <% end %> + <%= event_schedule_text(event) %>
  • <% end %>
-- cgit v1.3 From 2c54303b1ac08ec6b7382ece218f37d59db7052c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 00:53:40 +0200 Subject: Add direct edit link to nodes#index First application of subnav visual conventions to nodes: actions column now shows show | edit | revisions as plain informational links. edit intentionally uses edit_node_path (locking is expected behaviour for nodes, unlike event views where node_path is used to avoid inadvertent locking). --- app/views/nodes/index.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'app/views') diff --git a/app/views/nodes/index.html.erb b/app/views/nodes/index.html.erb index e5a55d4..2399105 100644 --- a/app/views/nodes/index.html.erb +++ b/app/views/nodes/index.html.erb @@ -21,7 +21,8 @@ <%= link_to 'show', node_path(node) %> - <%= link_to 'Revisions', node_revisions_path(node) %> + <%= link_to 'edit', edit_node_path(node) %> + <%= link_to 'revisions', node_revisions_path(node) %> <%= node.lock_owner.login if node.lock_owner %> -- cgit v1.3 From 175b408948e601d3db568768a309eed8f2edb307 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 15:13:52 +0200 Subject: Add 'open today' sidebar widget and open days in chapter lists - open_erfas_today helper: samples 3 random open-day-tagged occurrences of the current day; widget hidden entirely on days without open chapters; heading shows current weekday - content/_open_erfas_today partial rendered in right column above tags and featured articles - _chapter partial: open-day schedules (event_schedule_text) shown inline in aggregated chapter lists with 'Offene Tage' label - ccc.css: open_erfas_today added to existing right-column widget selector groups (headings, lists, links) --- app/helpers/content_helper.rb | 16 ++++++++++++++++ app/views/content/_open_erfas_today.html.erb | 11 +++++++++++ app/views/custom/partials/_chapter.html.erb | 9 +++++++++ app/views/layouts/application.html.erb | 1 + config/locales/de.yml | 2 ++ config/locales/en.yml | 2 ++ public/stylesheets/ccc.css | 10 +++++----- 7 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 app/views/content/_open_erfas_today.html.erb (limited to 'app/views') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index 53bf5b2..bf7287f 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -23,6 +23,22 @@ module ContentHelper ) end + def open_erfas_today + occurrences = Occurrence + .find_in_range(Time.now.beginning_of_day, Time.now.end_of_day) + .joins(event: :tags) + .where(tags: { name: 'open-day' }) + .reject { |o| o.node.nil? || o.node.head.nil? } + .sample(3) + + return if occurrences.empty? + + render( + :partial => 'content/open_erfas_today', + :locals => { :occurrences => occurrences } + ) + end + def tags render :partial => 'content/tags' end diff --git a/app/views/content/_open_erfas_today.html.erb b/app/views/content/_open_erfas_today.html.erb new file mode 100644 index 0000000..3448af3 --- /dev/null +++ b/app/views/content/_open_erfas_today.html.erb @@ -0,0 +1,11 @@ +
+

<%= t(:open_today) %>

+
    + <% occurrences.each do |occurrence| %> +
  • + <%= link_to_path occurrence.node.head.title, occurrence.node.unique_name %> + <%= occurrence.start_time.strftime("%H:%M") %> +
  • + <% end %> +
+
diff --git a/app/views/custom/partials/_chapter.html.erb b/app/views/custom/partials/_chapter.html.erb index a5b8662..805559b 100644 --- a/app/views/custom/partials/_chapter.html.erb +++ b/app/views/custom/partials/_chapter.html.erb @@ -6,5 +6,14 @@ <% if page.node.external_url.present? %>
<%= link_to page.node.external_url, page.node.external_url, target: '_blank', rel: 'noopener' %>
<% end %> + <% open_days = page.node.events.tagged_with('open-day').order(:start_time) %> + <% if open_days.any? %> +
+ <%= t(:open_days_label) %>: + <% open_days.each do |event| %> + <%= event_schedule_text(event) %> + <% end %> +
+ <% end %>

<%= sanitize page.body %>

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index edab5fc..57d12e6 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -72,6 +72,7 @@
+ <%= open_erfas_today %> <%= tags %> <%= featured_articles %>
diff --git a/config/locales/de.yml b/config/locales/de.yml index 9719a1c..e8ae4e1 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -11,7 +11,9 @@ de: event_schedule_unrecognized: "Termin-Regel nicht automatisch lesbar" event_schedule_none: "Kein Termin" open_days: "Offene Tage" + open_days_label: "Offene Tage" upcoming_events: "Weitere Termine" + open_today: "Heute dabei" date: formats: default: "%d.%m.%Y" diff --git a/config/locales/en.yml b/config/locales/en.yml index aa477f0..980a6b8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,7 +12,9 @@ en: event_schedule_unrecognized: "Schedule not automatically readable" event_schedule_none: "No schedule" open_days: "Open days" + open_days_label: "Open days" upcoming_events: "Upcoming events" + open_today: "Open today" time: formats: diff --git a/public/stylesheets/ccc.css b/public/stylesheets/ccc.css index 30eb8dd..422b7c0 100644 --- a/public/stylesheets/ccc.css +++ b/public/stylesheets/ccc.css @@ -216,7 +216,7 @@ div#frontpage_calendar { } } -div#frontpage_calendar h2, div#tags h2, div#featured_articles h2, div.main_navigation h2 { +div#frontpage_calendar h2, div#tags h2, div#featured_articles h2, div#open_erfas_today h2, div.main_navigation h2 { border-top: 2px solid; border-bottom: 2px solid; font-size: 1.1em; @@ -248,7 +248,7 @@ div#frontpage_calendar h2 { } @media(min-width:1016px) { - div#frontpage_calendar h2, div#tags h2, div#featured_articles h2 { + div#frontpage_calendar h2, div#tags h2, div#featured_articles h2, div#open_erfas_today h2 { font-size: 1rem; } @@ -260,7 +260,7 @@ div#frontpage_calendar h2 { } -div#frontpage_calendar ul, div#tags ul, div#featured_articles ul { +div#frontpage_calendar ul, div#tags ul, div#featured_articles ul, div#open_erfas_today ul { padding: 0px; font-size: 1rem; line-height: 1.5em; @@ -278,11 +278,11 @@ div#frontpage_calendar li { margin-bottom: 20px; } -div#frontpage_calendar li, div#tags li, div#featured_articles li { +div#frontpage_calendar li, div#tags li, div#featured_articles li, div#open_erfas_today li { list-style-type: none; } -div#frontpage_calendar li a, div#tags li a, div#featured_articles li a { +div#frontpage_calendar li a, div#tags li a, div#featured_articles li a, div#open_erfas_today li a { text-decoration: none; color: color-mix(in srgb, CanvasText, #808080 25%); } -- cgit v1.3 From 287569c9bbfdadae767254792cd2b53d0515d928 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 3 Jul 2026 03:22:02 +0200 Subject: Add weekday abbreviation for open-today widget RruleHumanizer gains WEEKDAY_NAMES_ABBR (Mo/Di/Mi/...) alongside the existing WEEKDAY_NAMES and WEEKDAY_NAMES_ADVERBIAL hashes, plus a self.wday_abbr(time, locale) utility mapping a Time's wday to its RFC5545 code and looking up the abbreviation - keeping RFC5545 vocabulary in one place rather than teaching ContentHelper about day codes directly. ContentHelper#weekday_abbr is a thin wrapper passing I18n.locale through. The partial now renders "Fr 19:00" instead of just "19:00", joined with a non-breaking space so the pair can't split across a line wrap. Takes an explicit Time rather than reading Date.today, matching this file's existing style of passing state in rather than reading it ambiently - and staying correct if the currently-unused calendar/_front_page_calendar partial (spanning six weeks, not just today) is ever revived and reuses this helper. --- app/helpers/content_helper.rb | 4 ++++ app/models/concerns/rrule_humanizer.rb | 9 +++++++++ app/views/content/_open_erfas_today.html.erb | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) (limited to 'app/views') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index bf7287f..6043089 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -39,6 +39,10 @@ module ContentHelper ) end + def weekday_abbr(time) + RruleHumanizer.wday_abbr(time, I18n.locale) + end + def tags render :partial => 'content/tags' end diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb index 6cee711..8231de8 100644 --- a/app/models/concerns/rrule_humanizer.rb +++ b/app/models/concerns/rrule_humanizer.rb @@ -10,6 +10,10 @@ module RruleHumanizer de: { "MO"=>"montags","TU"=>"dienstags","WE"=>"mittwochs","TH"=>"donnerstags","FR"=>"freitags","SA"=>"samstags","SU"=>"sonntags" } }.freeze + WEEKDAY_NAMES_ABBR = { + de: { "MO"=>"Mo","TU"=>"Di","WE"=>"Mi","TH"=>"Do","FR"=>"Fr","SA"=>"Sa","SU"=>"So" } + }.freeze + ORDINAL_NAMES = { de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } @@ -79,4 +83,9 @@ module RruleHumanizer base end + + def self.wday_abbr(time, locale) + code = %w[SU MO TU WE TH FR SA][time.wday] + (WEEKDAY_NAMES_ABBR[locale.to_sym] || WEEKDAY_NAMES_ABBR[:de])[code] + end end diff --git a/app/views/content/_open_erfas_today.html.erb b/app/views/content/_open_erfas_today.html.erb index 3448af3..468bedc 100644 --- a/app/views/content/_open_erfas_today.html.erb +++ b/app/views/content/_open_erfas_today.html.erb @@ -4,7 +4,7 @@ <% occurrences.each do |occurrence| %>
  • <%= link_to_path occurrence.node.head.title, occurrence.node.unique_name %> - <%= occurrence.start_time.strftime("%H:%M") %> + <%= weekday_abbr(occurrence.start_time) %> <%= occurrence.start_time.strftime("%H:%M") %>
  • <% end %> -- 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/views') 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 9c630d186003a47563be2e3547485060dac7dd98 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 4 Jul 2026 01:34:42 +0200 Subject: Retire event_information, superseded by nodes#show's event manager The metadata-panel "Event" field on nodes#edit called event_information, a second, tag-less implementation of "list events + add one" - now redundant now that nodes#show has the tag-aware version with the auto-tag flash explanation. Confirmed via grep this was the only call site before removing both it and the method definition. --- app/helpers/nodes_helper.rb | 15 --------------- app/views/nodes/edit.html.erb | 3 --- 2 files changed, 18 deletions(-) (limited to 'app/views') diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 093bfc6..2baf813 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -29,21 +29,6 @@ module NodesHelper User.all.map {|u| [u.login, u.id]} end - def event_information - events = @node.events.order(:start_time) - items = events.map do |event| - safe_join([ - "#{event.start_time&.to_fs(:db)} - #{event.end_time&.to_fs(:db)} > ", - link_to('edit', edit_event_path(event)), - ]) - end - safe_join([ - safe_join(items, ' | '), - ' > ', - link_to('add event', new_event_path(:node_id => @node.id)) - ]) - end - DEFAULT_EVENT_TAG_BY_PAGE_TAG = { 'erfa-detail' => 'open-day', 'chaostreff-detail' => 'open-day' diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 498fa1f..31f585c 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -15,9 +15,6 @@ <% end %>
    -
    Event
    -
    <%= event_information %>
    -
    Slug
    <%= f.text_field( -- 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/views') 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 9a7ce465b06462443ee82e8418f3c070de080ab2 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 5 Jul 2026 21:52:11 +0200 Subject: Surface node-kind conventions in the admin UI nodes#new: each kind's radio button now shows a label plus a smaller, muted .field_hint explaining what happens automatically (parent, tags, template) - previously a single undifferentiated string mixing a short label with a long parenthetical, cluttering the smaller kinds (erfa/chaostreff) worst. Title field gets the same hint treatment, telling editors up front that the slug is auto-generated and where to adjust it later - the previous silence here meant editors only discovered slug generation after already committing to a title, with no visible way to fix it. New "Resulting path" row live-previews the full URL (origin + kind's path prefix + a debounced call to the new parameterize_preview route, reusing the exact same slug_for helper "create" now calls, so the preview can never diverge from what actually gets created) as the editor types, plus a copy-to-clipboard button. For "generic", the prefix comes from whichever parent gets chosen via the search widget, not the registry - parent_search.link_closure now stashes the chosen node's unique_name as a data attribute for exactly this. New read-only admin/conventions view dumps the NODE_KINDS registry as a reference table for whoever's confused later about how a given kind behaves - deliberately just renders the same label/hint text used on nodes#new rather than re-describing parent/tags/template separately, since decomposing the parent Proc would mean either invoking it (Update's has a real side effect - creates the year folder) or re-stating its logic a second time in a different shape, either of which risks drifting from the actual behavior. nodes#edit's Template field gets a one-line hint noting the value may already be pre-filled based on how the node was created - the one place the previous commit's new inheritance mechanism actually surfaces on this view. --- app/helpers/application_helper.rb | 4 ++++ app/views/nodes/edit.html.erb | 5 ++++- app/views/nodes/new.html.erb | 34 +++++++++++++++++++--------------- config/routes.rb | 1 + public/stylesheets/admin.css | 12 ++++++++++++ 5 files changed, 40 insertions(+), 16 deletions(-) (limited to 'app/views') diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0be66e9..87aa82c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,4 +11,8 @@ module ApplicationHelper end end end + + def resolve_kind_text(value) + value.respond_to?(:call) ? value.call : value + end end diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 31f585c..2d0edc1 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -44,7 +44,10 @@
    <%= d.datetime_select :published_at, :value => @draft.published_at %>
    Template
    -
    <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
    +
    + <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %> + Set automatically based on how this node was created - change it if needed. +
    Author
    <%= d.select :user_id, user_list %>
    diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 028d727..f557454 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -13,27 +13,23 @@ Type -
    - <%= radio_button_tag :kind, "top_level" %> - Top Level -
    -

    - <%= radio_button_tag :kind, "generic", :selected => true %> - Generic ( can be created anywhere ) -

    -

    - <%= radio_button_tag :kind, "update" %> - Update ( is automatically created in /updates/<%= Time.now.year.to_s %>/ and gets tag "update" ) -

    + <% CccConventions::NODE_KINDS.each do |kind, config| %>

    - <%= radio_button_tag :kind, "press_release" %> - Pressemitteilung ( is automatically created in /updates/<%= Time.now.year.to_s %>/ and gets tags "update, pressemitteilung" ) + <%= radio_button_tag :kind, kind, kind == "generic", + data: { path_prefix: resolve_kind_text(config[:path_prefix]) } %> + <%= resolve_kind_text(config[:label]) %> + <% if config[:hint] %> + <%= resolve_kind_text(config[:hint]) %> + <% end %>

    + <% end %> Title - <%= text_field_tag :title %> + <%= text_field_tag :title %> + A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field. + Parent @@ -45,6 +41,14 @@
    + + Resulting path + + + + This preview updates as you type. The final path can still be changed afterward by editing the title (for the last segment) or moving the node to a different parent (for everything before it). + + <%= submit_tag "Create" %> diff --git a/config/routes.rb b/config/routes.rb index cf4733c..6c07414 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -59,6 +59,7 @@ Cccms::Application.routes.draw do match 'admin' => 'admin#index', :as => :admin, :via => :get match 'admin/search' => 'admin#search', :as => :admin_search, :via => :get match 'admin/menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get + match 'admin/conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get match 'search' => 'search#index', :as => :search, :via => :get resources :users diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index a02bd61..f240bb2 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -508,6 +508,14 @@ table tr.header { text-align: left; } +.field_hint { + display: block; + margin-top: 2px; + font-size: 0.75rem; + color: #969696; + padding-bottom: 4px; +} + #search_widget { position: absolute; top: 20px; @@ -527,18 +535,21 @@ table tr.header { font-size: 18px; } +#menu_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 { display: block; font-weight: bold; font-size: 0.95rem; } +#menu_search_results p span.result_path, #search_results p span.result_path { display: block; margin-top: 2px; @@ -548,6 +559,7 @@ table tr.header { padding-bottom: 4px; } +#menu_search_results p.search_more, #search_results p.search_more { margin: 0; padding: 6px 4px; -- cgit v1.3 From 4c47031f9693fc04b01cbb005ad864cd1e3d2425 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 04:15:46 +0200 Subject: Add erfa/chaostreff node-kind creation conventions, part 2 The following changes were already announced in the last commit, but the files forgotten. Here's them actually attached. 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. New read-only admin/conventions view dumps the NODE_KINDS registry as a reference table for whoever's confused later about how a given kind behaves - deliberately just renders the same label/hint text used on nodes#new rather than re-describing parent/tags/template separately, since decomposing the parent Proc would mean either invoking it (Update's has a real side effect - creates the year folder) or re-stating its logic a second time in a different shape, either of which risks drifting from the actual behavior. --- app/views/admin/conventions.html.erb | 11 +++++++++ lib/ccc_conventions.rb | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 app/views/admin/conventions.html.erb create mode 100644 lib/ccc_conventions.rb (limited to 'app/views') diff --git a/app/views/admin/conventions.html.erb b/app/views/admin/conventions.html.erb new file mode 100644 index 0000000..72f6214 --- /dev/null +++ b/app/views/admin/conventions.html.erb @@ -0,0 +1,11 @@ +

    Node creation conventions

    + + + + <% @node_kinds.each do |kind, config| %> + + + + + <% end %> +
    KindWhat happens
    <%= kind %><%= resolve_kind_text(config[:label]) %> — <%= resolve_kind_text(config[:hint]) %>
    diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb new file mode 100644 index 0000000..c0f9943 --- /dev/null +++ b/lib/ccc_conventions.rb @@ -0,0 +1,47 @@ +module CccConventions + ERFA_PARENT_NAME = "club/erfas" + CHAOSTREFF_PARENT_NAME = "club/chaostreffs" + + NODE_KINDS = { + "top_level" => { + parent: -> { Node.root }, + 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 + }, + "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." } + }, + "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." } + }, + "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." + }, + "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." + } + }.freeze +end -- cgit v1.3 From 970f10854bccee0528de8435e5a65cdcc18ba93e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 04:20:55 +0200 Subject: Remove dead custom_rrule references after column drop events#index still read event.custom_rrule - a live bug the column-drop migration introduced, not just stale test data. Caught by four test failures (three fixtures/direct attribute hashes still setting the removed column, one UnknownAttributeError from a stale fixture loaded before any test method runs), none of which were actually testing this view - "should get index" passed throughout with zero Event records present, meaning it could never have caught a per-row rendering bug. Strengthened to create one real event first, so a future stray reference to a dropped or renamed column fails loudly instead of silently passing on an empty table. --- app/views/events/index.html.erb | 2 -- test/controllers/events_controller_test.rb | 3 +++ test/models/event_test.rb | 4 ---- 3 files changed, 3 insertions(+), 6 deletions(-) (limited to 'app/views') diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index d0458d7..ae4f477 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -6,7 +6,6 @@ Start time End time Rrule - Custom rrule Allday Url Node @@ -18,7 +17,6 @@ <%=h event.start_time %> <%=h event.end_time %> <%=h event.rrule %> - <%=h event.custom_rrule %> <%=h event.allday %> <%=h event.url %> <%= event.node ? link_to(event.node_id, node_path(event.node)) : '' %> diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index 9371ca7..46f3f4f 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -4,6 +4,9 @@ class EventsControllerTest < ActionController::TestCase test "should get index" 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 :index assert_response :success assert_not_nil assigns(:events) diff --git a/test/models/event_test.rb b/test/models/event_test.rb index d85aadd..e98605e 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -29,7 +29,6 @@ class EventTest < ActiveSupport::TestCase :longitude => 13.378944, :rrule => "FOOBAR", :allday => false, - :custom_rrule => false, :node_id => @cal_node.id ) end @@ -44,7 +43,6 @@ class EventTest < ActiveSupport::TestCase :longitude => 13.378944, :rrule => nil, :allday => false, - :custom_rrule => false, :node_id => @cal_node.id ) @@ -62,7 +60,6 @@ class EventTest < ActiveSupport::TestCase :longitude => 13.378944, :rrule => "FREQ=WEEKLY;INTERVAL=1", :allday => false, - :custom_rrule => false, :node_id => @cal_node.id ) @@ -97,7 +94,6 @@ class EventTest < ActiveSupport::TestCase :longitude => 13.378944, :rrule => "FREQ=MONTHLY;INTERVAL=1;BYDAY=-1WE", :allday => false, - :custom_rrule => true, :node_id => @cal_node.id ) -- 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/views') 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 36b5324900968afc7498950c14ad13be5c061a7c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 16:49:45 +0200 Subject: Restructure nodes#show and nodes#new around node_description/node_content nodes#show's flat of label/value pairs grew unreadable once Admin Preview and Public Preview were added alongside Public Link - switched to the div.node_description/div.node_content pattern already used by nodes#edit, grouped into People/Dates/Links/Revisions/Tags/ Events/Children sections rather than one undifferentiated list. People/Dates/Links use a flex-based item layout, not a second
    - multiple independent tables sharing one outer width but different column counts produced misaligned columns with no shared grid. Flex items size to their own content with a shared minimum instead. Revisions and Children are both collapsed via
    , no JS needed - previously nodes#show didn't list a node's children at all, blocking the ability to find/navigate erfa and chaostreff pages without already knowing they're now standalone nodes. Revision list items link to node_revision_path(@node, page) - the actual per-revision diff/restore view, not the plain index. nodes#new converted to the same pattern for visual consistency, plus a few things surfaced along the way: submit buttons were unstyled site-wide (a bare input[type=submit] with no border, fill, or hover state - Create on this exact page was easy to miss entirely), fixed with a bordered/bold treatment reusing the existing form.button_to visual language rather than introducing a fourth button style. Title regained the bold weight it had before the table rewrite dropped it silently. Tag list grouping and full link/button semantic taxonomy (show vs edit/add vs publish/revoke vs destroy, applied consistently across every link on the page, not just buttons) are known follow-ups, not attempted here. --- app/views/nodes/new.html.erb | 57 ++++++------ app/views/nodes/show.html.erb | 204 ++++++++++++++++++++++++------------------ public/stylesheets/admin.css | 64 +++++++++---- 3 files changed, 192 insertions(+), 133 deletions(-) (limited to 'app/views') diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index f557454..0a05325 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -6,13 +6,11 @@ <% end %> -

    What kind of node do you want to create?

    - <%= form_tag nodes_path do %> -
    - - - - - - - - - - - - - - - - - - - - -
    Type +
    + +
    Type
    +
    <% CccConventions::NODE_KINDS.each do |kind, config| %>

    <%= radio_button_tag :kind, kind, kind == "generic", @@ -23,35 +21,32 @@ <% end %>

    <% end %> -
    Title<%= text_field_tag :title %> + + +
    Title
    +
    + <%= text_field_tag :title %> A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field. -
    Parent + + +
    +
    Parent
    +
    <%= text_field_tag :parent_search_term, @parent_name %> <%= hidden_field_tag :parent_id, @parent_id %> -
    - -
    -
    Resulting path +
    + + + +
    Resulting path
    +
    This preview updates as you type. The final path can still be changed afterward by editing the title (for the last segment) or moving the node to a different parent (for everything before it). -
    <%= submit_tag "Create" %>
    + + +
    +
    <%= submit_tag "Create" %>
    + + <% end %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index d63df86..189adb8 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -1,95 +1,129 @@ <% content_for :subnavigation do %> <%= link_to 'Edit', edit_node_path(@node), :class => "unselected" %> <%= link_to 'Preview', preview_page_path(@page) %> - <%= link_to 'Revisions', node_revisions_path(@node) %> - <%= unlock_link if @node.locked? %> <% end %> -
    - - - - - - <% if @node.draft %> - - - - - - - - - <% end %> - - - - - - - - - <% if @page.node.locked? %> - - - - - <% end %> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Public Link<%= link_to @page.public_link, content_url(@node.unique_path) %>
    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 %> +
    + +
    People
    +
    +
    +
    + Author + <%= @page.user.try(:login) %> +
    +
    + Editor + <%= @page.editor.try(:login) %> +
    + <% if @node.locked? %> +
    + Locked by + <%= @node.lock_owner.login %> + <%= unlock_link %> +
    <% end %> -
    Author<%= @page.user.try(:login) %>
    Editor<%= @page.editor.try(:login) %>
    Locked by<%= @page.node.lock_owner.login %>
    Last updated<%= @page.updated_at %>
    Published at<%= @page.published_at %>
    Revision<%= @page.revision %>
    Tagged with:<%= @page.tag_list %>
    Events -
      - <% @node.events.order(:start_time).each do |event| %> -
    • - <%= event_schedule_text(event) %> - [<%= link_to 'show', event_path(event, return_to: request.path) %> - | <%= link_to 'edit', edit_event_path(event, return_to: request.path) %>] -
    • + + + +
      Dates
      +
      +
      +
      + Last updated + <%= @page.updated_at %> +
      + <% if @page.published_at.present? %> +
      + <%= @page.public? ? 'Published at' : 'Will publish at' %> + <%= @page.published_at %> +
      + <% end %> +
      +
      + +
      Links
      +
      +
      +
      + Public + <%= 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 %> +
      +
      + +
      Revisions
      +
      +
      + + <%= pluralize(@node.pages.count, 'revision', 'revisions') %> + · <%= link_to 'full history (diff / restore)', node_revisions_path(@node) %> + +
        + <% @node.pages.order(:revision).each do |page| %> +
      • <%= link_to "##{page.revision} — #{page.title} (#{page.user.try(:login)}, #{page.updated_at})", node_revision_path(@node, page) %>
      • + <% 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) %> -
    Title<%= sanitize( @page.title ) %>
    Abstract<%= sanitize( @page.abstract ) %>
    Body<%= sanitize( @page.body ) %>
    + +
    + +
    Tags
    +
    <%= @page.tag_list %>
    + +
    Events
    +
    +
      + <% @node.events.order(:start_time).each do |event| %> +
    • + <%= event_schedule_text(event) %> + [<%= link_to 'show', event_path(event, return_to: request.path) %> + | <%= link_to 'edit', edit_event_path(event, return_to: request.path) %>] +
    • + <% 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) %> +
    + + <% if @node.children.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) %>
    • + <% end %> +
    +
    +
    + <% end %> + +
    Title
    +
    <%= sanitize(@page.title) %>
    + +
    Abstract
    +
    <%= sanitize(@page.abstract) %>
    + +
    Body
    +
    <%= sanitize(@page.body) %>
    + <%# Assets not yet addressed - no confirmed model/association seen for this page's attached assets %> + + diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index f240bb2..1196d83 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -106,7 +106,6 @@ div.pagination span.current, div.pagination a:hover { #main_navigation a, #sub_navigation a, #metadata a, -input[type=submit], #overview_toggle a { letter-spacing: 1px; padding-left: 5px; @@ -316,17 +315,23 @@ input[type=password] { border: 1px solid #989898; } -input[type=submit] { - border: none; - padding-top: 0px; - padding-bottom: 1px; - background-color: #ffffff; +input[type="submit"] { + -webkit-appearance: none; + appearance: none; + background: none; + border: 1px solid #000000; + border-radius: 2px; + padding: 4px 12px; + font: inherit; + font-weight: bold; + color: inherit; + cursor: pointer; + text-decoration: none; } -input[type=submit]:hover { - border: none; +input[type="submit"]:hover { color: #ffffff; - background-color: #ff9600; + background-color: #000000; } input[type=radio] { @@ -516,6 +521,39 @@ table tr.header { padding-bottom: 4px; } +.node_content.node_info_group { + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 0.5rem 0.75rem; +} + +.node_info_group_items { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 2rem; +} + +.node_info_item { + min-width: 140px; +} + +.node_info_label { + display: block; + font-weight: normal; + color: #969696; + font-size: 0.85rem; + text-transform: lowercase; +} + +.node_info_group ul { + margin: 0; + padding-left: 1.25rem; +} + +.node_info_group li { + margin-bottom: 0.25rem; +} + #search_widget { position: absolute; top: 20px; @@ -569,14 +607,6 @@ table tr.header { border-bottom: none; } -table#content th.description { - width: 100px; -} - -table#content th.content { - width: 690px; -} - #menu_item_list { border-collapse: collapse; padding: 5px 5px 5px 5px; -- 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/views') 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 ae29567bea75b28bd3f5655b6bf3e6444d51eb38 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:41:36 +0200 Subject: Reorganize admin.css and overhaul button/link/flash visual hierarchy Sorts rules by hierarchy (base -> layout -> navigation -> buttons -> tables -> forms -> components), consolidates three duplicated media query breakpoints into one each, and adds section comments. Bundled with this reorganization, since the file was rewritten wholesale: - form.button_to.state_changing: new tinted-pill variant (blue) for Publish/Restore/Unlock/Revoke, matching .destructive's pattern - form.button_to.destructive: now a tinted pill at rest instead of plain colored text, consistent padding at rest and on hover - Scoped wavy-underline link-visibility fix for #page_editor, table.node_table, table.assets_table, table.events_table, table.user_table, .add_child_links, and the dashboard draft list - #flash decoupled from the page's structural nav-to-content spacing (.admin_content_spacer), which the flash div was silently providing as a side effect whenever it rendered --- app/views/layouts/admin.html.erb | 4 +- public/stylesheets/admin.css | 709 ++++++++++++++++++++++----------------- 2 files changed, 413 insertions(+), 300 deletions(-) (limited to 'app/views') diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index a7ce68a..340eaf2 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -29,13 +29,15 @@ <%= yield :subnavigation %>
    -
    +
    + <% if flash[:notice].present? || flash[:error].present? %>
    <%= flash[:notice] %> <% if flash[:error] %> <%= flash[:error] %> <% end %>
    + <% end %>
    <%= yield %>
    diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 3033798..94248a6 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -1,4 +1,6 @@ -/* Layout */ +/* ============================================================ + Base elements + ============================================================ */ body { font-family: Helvetica, Arial, sans-serif; @@ -15,33 +17,160 @@ a:hover { color: #ff9600; } +/* Scoped link visibility: a light, desaturated squiggly underline + signals "clickable" at rest without the weight of a solid line. + Hover still relies on the global a:hover color change above -- this + only adds an at-rest affordance for plain content links that would + otherwise be indistinguishable from body text. Covers: page editor + body content, node listing tables, the child-creation shortcuts on + nodes#show, and the dashboard draft list's Show/Revisions links. */ +#page_editor a, +table.node_table a, +table.assets_table a, +table.events_table a, +table.user_table a, +.add_child_links a, +div#draft_list table td.actions 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; +} + th { text-transform: lowercase; } +td { + padding-top: 10px; + padding-bottom: 5px; + padding-right: 10px; +} +/* table#revisions td only overrides padding-right, so its rows still + depend on this rule for vertical padding. table.node_table td resets + padding-top/bottom to 0 explicitly and does not depend on it. */ + +input[type=text], textarea { + font-size: 1rem; + font-family: Helvetica; + border: 1px solid #989898; +} + +select { + font-size: 1rem; + border: 1px solid #989898; +} + +input[type=password] { + border: 1px solid #989898; +} + +input[type=radio] { + border: 1px solid #989898; +} + +/* ============================================================ + Layout + ============================================================ */ + +@media(min-width:1016px) { + #wrapper { + margin: 0 125px; + } +} +@media(max-width:1015px) { + #wrapper { + margin: 0; + width: 100%; + box-sizing: border-box; + } +} + #metadata, #content { margin-left: 5px; } -@media(min-width:1016px) { -#wrapper { - margin: 0 125px; +.right { text-align: right; } +.clear_left { clear: left; } + +/* ============================================================ + Navigation + ============================================================ */ + +#navigation { + position: relative; + margin-top: 10px; } + +#navigation div { float: left; } + +#navigation a:hover { + color: #ffffff; + background-color: #000000; } -@media(max-width:1015px) { -#wrapper { - margin: 0; - width: 100%; - box-sizing: border-box; + +#navigation a.selected, +#metadata a.selected, +#overview_toggle a.selected { + color: #ffffff; + background-color: #000000; } + +#main_navigation a, +#sub_navigation a, +#metadata a, +#overview_toggle a { + letter-spacing: 1px; + padding-left: 5px; + padding-right: 5px; + padding-top: 1px; + padding-bottom: 1px; + text-transform: lowercase; +} + +#metadata a:hover { + color: #ffffff; + background-color: #ff9600; +} + +/* #sub_navigation covers 13 views' Show/Preview/Revisions/metadata + links (see `grep -rn "content_for :subnavigation" app/views/`), + including nodes#edit -- so #page_editor's own subnav links are + already covered here and are not part of the new scoped-underline + rule above. Proposed for full removal per the redesign notes; the + 13-file list is the confirmation needed before scoping that work. */ +#sub_navigation a { + color: #969696; +} + +#sub_navigation a:hover { + color: #ffffff; + background-color: #ff9600; +} + +#sub_navigation a.selected { + color: #ffffff; + background-color: #ff9600; } +.admin_content_spacer { + clear: both; + margin-bottom: 40px; +} + +/* ============================================================ + Flash / notices + ============================================================ */ #flash { - height: 12px; + height: auto; line-height: 20px; - margin-top: 40px; + padding: 6px 10px; + border-left: 3px solid #969696; + background-color: #f1f1f1; margin-bottom: 20px; margin-left: 5px; } @@ -52,7 +181,6 @@ th { text-transform: lowercase; } - #flash img { height: 12px; width: 12px; @@ -68,7 +196,9 @@ span#flash_error, span.warning { padding-bottom: 1px; } -/* Pagination */ +/* ============================================================ + Pagination + ============================================================ */ div.pagination { padding-top: 5px; @@ -79,43 +209,12 @@ div.pagination span.current, div.pagination a:hover { color: #ff9600; } -/* Styles */ - -.right { - text-align: right; -} - -.clear_left { - clear: left; -} - -#navigation { - position: relative; - margin-top: 10px; -} - -#navigation div { - float: left; -} - -#navigation a:hover { - color: #ffffff; - background-color: #000000; -} - -#main_navigation a, -#sub_navigation a, -#metadata a, -#overview_toggle a { - letter-spacing: 1px; - padding-left: 5px; - padding-right: 5px; - padding-top: 1px; - padding-bottom: 1px; - text-transform: lowercase; -} +/* ============================================================ + Buttons + ============================================================ */ -/* button_to forms: render submit inputs as plain links */ +/* button_to forms: render submit inputs/buttons as plain links, + orange on hover; .destructive variant is red. */ form.button_to { display: inline; margin: 0; @@ -136,31 +235,64 @@ form.button_to button[type="submit"] { text-decoration: none; } -form.button_to input[type="submit"]:hover, -form.button_to button[type="submit"]:hover { - color: #ff9600; +form.button_to.state_changing input[type="submit"], +form.button_to.state_changing button[type="submit"] { + color: #0d47a1; + background-color: #e3f2fd; + border-radius: 2px; + padding: 2px 8px; +} + +form.button_to.state_changing input[type="submit"]:hover, +form.button_to.state_changing button[type="submit"]:hover { + color: #ffffff; + background-color: #1565c0; } form.button_to.destructive input[type="submit"], form.button_to.destructive button[type="submit"] { color: #cc0000; + background-color: #fdecea; + border-radius: 2px; + padding: 2px 8px; } form.button_to.destructive input[type="submit"]:hover, form.button_to.destructive button[type="submit"]:hover { color: #ffffff; background-color: #cc0000; +} + +/* Native (non-button_to) submit buttons -- Create/Save/Publish forms. + Lower specificity than form.button_to's own rule, so button_to forms + are correctly unaffected. If a button_to output ever loses its class + wrapper, it will silently fall through to this bordered style instead + of rendering as a plain link -- worth knowing, not necessarily fixing. */ +input[type="submit"] { + -webkit-appearance: none; + appearance: none; + background: none; + border: 1px solid #000000; border-radius: 2px; - padding: 0 3px; + padding: 4px 12px; + font: inherit; + font-weight: bold; + color: inherit; + cursor: pointer; + text-decoration: none; } -#admin_wizard { - margin-bottom: 2rem; +input[type="submit"]:hover { + color: #ffffff; + background-color: #000000; } -#page_submit a, -.admin_wizard_button a { - color: white !important; +/* ============================================================ + Wizard buttons + ============================================================ */ + +#admin_wizard { + margin-bottom: 2rem; } #page_submit, @@ -173,32 +305,30 @@ form.button_to.destructive button[type="submit"]:hover { background-color: green; } +#page_submit a, +.admin_wizard_button a { + color: white !important; +} + #page_submit:hover, .admin_wizard_button:hover { background-color: lime !important; } -#sub_navigation a { - color: #969696; -} +/* ============================================================ + Tables + ============================================================ */ -#sub_navigation a:hover, #metadata a:hover { - color: #ffffff; - background-color: #ff9600; -} - -#sub_navigation a.selected { - color: #ffffff; - background-color: #ff9600; -} - -#navigation a.selected, #metadata a.selected, #overview_toggle a.selected { - color: #ffffff; - background-color: #000000; +/* Generic fallback header row. Shadowed by table.node_table's own + tr.header (2rem) and duplicated (same values) by table#revisions's + own tr.header below. Applies as-is to #menu_item_list, + div#draft_list table, and table.user_table, none of which define + their own tr.header. */ +table tr.header { + height: 20px; + text-align: left; } -/* Nodes */ - table.node_table { border-collapse: collapse; } @@ -214,6 +344,9 @@ table.node_table th.title { min-width: 4rem; } +/* th.title's min-width is set to 4rem above and 12rem here -- the + later, equally-specific rule wins, so th.title ends up at 12rem and + the 4rem entry for it above is dead. Likely a copy/paste leftover. */ table.node_table th.editor, table.node_table th.title { min-width: 12rem; @@ -237,6 +370,10 @@ table.node_table .node_id { padding-right: 15px; } +table.node_table .actions { + text-transform: lowercase; +} + table.node_table tr.header:hover { background-color: #ffffff; } @@ -245,20 +382,25 @@ table.node_table tr:hover { background-color: #f1f1f1; } -table.node_table .actions { - text-transform: lowercase; -} - #admin_sitemap_table .node_id:before { content: "• "; } -/* Revisions */ - table#revisions { border-collapse: collapse; } +table#revisions tr { + height: 45px; +} + +/* Redundant with the generic table tr.header rule above (identical + values) -- harmless, kept presumably for explicitness. */ +table#revisions tr.header { + height: 20px; + text-align: left; +} + table#revisions td { padding-right: 25px; } @@ -283,61 +425,91 @@ table#revisions tr:hover { background-color: #f1f1f1; } -table#revisions tr.header { - height: 20px; - text-align: left; +table.user_table td.user_login { + padding-right: 30px; } -table#revisions tr { - height: 45px +/* ============================================================ + Node display (nodes#show / nodes#new) + ============================================================ */ + +@media(min-width:1016px) { + div.node_description { + float: left; + width: 110px; + min-height: 2rem; + text-align: right; + text-transform: lowercase; + vertical-align: top; + } + + div.node_content { + margin-left: 120px; + min-height: 2rem; + display: block; + margin-bottom: 1rem; + } } +@media(max-width:1015px) { + div.node_description { + min-height: 2rem; + width: 30em; + text-transform: lowercase; + vertical-align: top; + } -/* Page Editor */ + div.node_content { + min-height: 2rem; + margin-bottom: 1rem; + } +} -input[type=text], textarea { - font-size: 1rem; - font-family: Helvetica; - border: 1px solid #989898; +.node_content.node_info_group { + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 0.5rem 0.75rem; } -select { - font-size: 1rem; - border: 1px solid #989898; +.node_info_group_items { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 2rem; } -input[type=text]#tag_list, -input[type=text]#node_staged_slug, -input#move_to_search_term { - padding: 5px; +.node_info_item { + min-width: 140px; } -input[type=password] { - border: 1px solid #989898; +.node_info_label { + display: block; + font-weight: normal; + color: #969696; + font-size: 0.85rem; + text-transform: lowercase; } -input[type="submit"] { - -webkit-appearance: none; - appearance: none; - background: none; - border: 1px solid #000000; - border-radius: 2px; - padding: 4px 12px; - font: inherit; - font-weight: bold; - color: inherit; - cursor: pointer; - text-decoration: none; +.node_info_group ul { + margin: 0; + padding-left: 1.25rem; } -input[type="submit"]:hover { - color: #ffffff; - background-color: #000000; +.node_info_group li { + margin-bottom: 0.25rem; } -input[type=radio] { - border: 1px solid #989898; +.field_hint { + display: block; + margin-top: 2px; + font-size: 0.75rem; + color: #969696; + padding-bottom: 4px; } +/* 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. */ +/* Layout only -- the at-rest visibility (wavy underline) for these + links comes from the scoped rule in Base elements above. */ .add_child_links { margin-top: 0.5rem; } @@ -346,92 +518,9 @@ input[type=radio] { white-space: nowrap; } -div#login_form input[type=text], div#login_form input[type=password] { - width: 150px; -} - - -@media(min-width:1016px) { -input#tag_list, -input#node_staged_slug, -input#move_to_search_term { - width: 690px; -} - -input[type=text]#page_title { - width: 690px; - font-size: 1rem; - padding-top: 6px; - padding-bottom: 4px; - padding-left: 5px; - padding-right: 5px; - font-weight: bold; -} - -textarea#page_abstract { - width: 690px; - height: 150px; - padding: 5px; -} - -#page_editor textarea#page_body { - width: 700px; - height: 600px; -} - -#page_editor #metadata, #page_editor #content, #admin_overview { - margin-left: -125px; -} - -} - -/* Mobile */ -@media(max-width:1015px) { -input#tag_list, -input#node_staged_slug, -input#move_to_search_term { - box-sizing: border-box; - width: 100%; -} - -#page_editor #content, -#page_editor #metadata { - padding-right: 5px; -} - -#page_editor textarea#page_body { - box-sizing: border-box; - width: 800px; - height: 50rem; -} - -#page_editor textarea#page_abstract { - box-sizing: border-box; - height: 8rem; - width: 100%; -} - -input[type=text]#page_title { - box-sizing: border-box; - width: 100%; - font-size: 2rem; - padding-top: 6px; - padding-bottom: 4px; - padding-left: 5px; - font-weight: bold; -} - -input[type=text], textarea { - font-size: 1.5rem; -} -select { - font-size: 1.5rem; -} - -#metadata ul#image_box { - width: 100% !important; -} -} +/* ============================================================ + Page editor / metadata forms + ============================================================ */ #new_node { margin-left: -118px; @@ -441,44 +530,14 @@ select { width: 350px; } -tr {} - -td { - padding-top: 10px; - padding-bottom: 5px; - padding-right: 10px; -} - -@media(min-width:1016px) { -div.node_description { - float: left; - width: 110px; - min-height: 2rem; - text-align: right; - text-transform: lowercase; - vertical-align: top; -} - -div.node_content { - margin-left: 120px; - min-height: 2rem; - display: block; - margin-bottom: 1rem; -} -} -@media(max-width:1015px) { -div.node_description { - min-height: 2rem; - width: 30em; - text-transform: lowercase; - vertical-align: top; -} - -div.node_content { - min-height: 2rem; - margin-bottom: 1rem; +div#login_form input[type=text], div#login_form input[type=password] { + width: 150px; } +input[type=text]#tag_list, +input[type=text]#node_staged_slug, +input#move_to_search_term { + padding: 5px; } div#page_editor { @@ -486,6 +545,95 @@ div#page_editor { margin-left: 10px; } +@media(min-width:1016px) { + input#tag_list, + input#node_staged_slug, + input#move_to_search_term { + width: 690px; + } + + input[type=text]#page_title { + width: 690px; + font-size: 1rem; + padding-top: 6px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + font-weight: bold; + } + + textarea#page_abstract { + width: 690px; + height: 150px; + padding: 5px; + } + + #page_editor textarea#page_body { + width: 700px; + height: 600px; + } + + #page_editor #metadata, #page_editor #content, #admin_overview { + margin-left: -125px; + } +} + +@media(max-width:1015px) { + input#tag_list, + input#node_staged_slug, + input#move_to_search_term { + box-sizing: border-box; + width: 100%; + } + + #page_editor #content, + #page_editor #metadata { + padding-right: 5px; + } + + /* Fixed 800px width will overflow narrow viewports despite + border-box -- every sibling rule in this block uses 100% instead. + Worth checking on an actual phone; looks unfinished rather than + intentional. Not changed here since it wasn't part of what you + asked to fix. */ + #page_editor textarea#page_body { + box-sizing: border-box; + width: 800px; + height: 50rem; + } + + #page_editor textarea#page_abstract { + box-sizing: border-box; + height: 8rem; + width: 100%; + } + + input[type=text]#page_title { + box-sizing: border-box; + width: 100%; + font-size: 2rem; + padding-top: 6px; + padding-bottom: 4px; + padding-left: 5px; + font-weight: bold; + } + + input[type=text], textarea { + font-size: 1.5rem; + } + select { + font-size: 1.5rem; + } + + #metadata ul#image_box { + width: 100% !important; + } +} + +/* ============================================================ + Draft list (dashboard widget) + ============================================================ */ + div#draft_list { border: 1px solid #000000; padding: 5px; @@ -501,66 +649,26 @@ div#draft_list table tr.header:hover { background-color: #ffffff; } +div#draft_list table tr:hover { + background-color: #f1f1f1; +} + div#draft_list table td.actions { text-transform: lowercase; white-space: nowrap; } +/* Layout only -- the at-rest visibility (wavy underline) for these + links comes from the scoped rule in Base elements above. */ div#draft_list table td.actions a { letter-spacing: 1px; padding-left: 5px; padding-right: 5px; } -div#draft_list table tr:hover { - background-color: #f1f1f1; -} - -table tr.header { - height: 20px; - text-align: left; -} - -.field_hint { - display: block; - margin-top: 2px; - font-size: 0.75rem; - color: #969696; - padding-bottom: 4px; -} - -.node_content.node_info_group { - border: 1px solid #e8e8e8; - border-radius: 6px; - padding: 0.5rem 0.75rem; -} - -.node_info_group_items { - display: flex; - flex-wrap: wrap; - gap: 0.5rem 2rem; -} - -.node_info_item { - min-width: 140px; -} - -.node_info_label { - display: block; - font-weight: normal; - color: #969696; - font-size: 0.85rem; - text-transform: lowercase; -} - -.node_info_group ul { - margin: 0; - padding-left: 1.25rem; -} - -.node_info_group li { - margin-bottom: 0.25rem; -} +/* ============================================================ + Search widgets + ============================================================ */ #search_widget { position: absolute; @@ -615,6 +723,10 @@ table tr.header { border-bottom: none; } +/* ============================================================ + Menu items (navigation editor) + ============================================================ */ + #menu_item_list { border-collapse: collapse; padding: 5px 5px 5px 5px; @@ -631,6 +743,10 @@ table tr.header { height: 20px; } +#menu_item_list td.menu_item_title { + width: 200px; +} + #menu_item_list td.menu_sort_handle div { background-color: #989898; height: 26px; @@ -649,14 +765,14 @@ table tr.header { cursor: grab; } -#menu_item_list td.menu_item_title { - width: 200px; -} - -.ui-state-highlight td{ +.ui-state-highlight td { height: 20px; } +/* ============================================================ + Image box / browser + ============================================================ */ + #metadata ul#image_box { box-sizing: border-box; margin: 0; @@ -681,8 +797,3 @@ div#image_browser { div#image_browser ul li { list-style-type: none; } - -table.user_list td.user_login { - padding-right: 30px; -} - -- cgit v1.3 From 42f81016be8c55322e0dbb2c20b08812d5d2228b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:43:21 +0200 Subject: Adopt button pills in views --- app/helpers/link_helper.rb | 2 +- app/views/admin/_drafts.html.erb | 2 +- app/views/nodes/edit.html.erb | 2 +- app/views/nodes/show.html.erb | 4 ++-- app/views/revisions/index.html.erb | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) (limited to 'app/views') diff --git a/app/helpers/link_helper.rb b/app/helpers/link_helper.rb index 878e8e4..9b0b4e1 100644 --- a/app/helpers/link_helper.rb +++ b/app/helpers/link_helper.rb @@ -44,7 +44,7 @@ module LinkHelper "Last modified #{@page.updated_at.to_fs(:db)}" button_to 'Unlock', unlock_node_path(@node), method: :put, - form: { data: { confirm: message } } + form: { data: { confirm: message }, class: 'button_to state_changing' } end def content_path(page_path = nil, options = {}) diff --git a/app/views/admin/_drafts.html.erb b/app/views/admin/_drafts.html.erb index a35b0ab..4ef568f 100644 --- a/app/views/admin/_drafts.html.erb +++ b/app/views/admin/_drafts.html.erb @@ -17,7 +17,7 @@ <%= link_to 'Show', node_path(draft.node) %> <%= link_to "Revisions", revision_path(draft.node.id) %> - <%= button_to "Publish", publish_node_path(draft.node), method: :put, form: { data: { confirm: "Do you really want to publish?" } } %> + <%= button_to "Publish", publish_node_path(draft.node), method: :put, form: { data: { confirm: "Do you really want to publish?" }, class: 'button_to state_changing' } %> <% end %> diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 2d0edc1..4cdb3ae 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -2,7 +2,7 @@ <%= link_to 'metadata', '#', :id => 'button', :class => "unselected" %> <%= link_to 'Show', @node %> <%= link_to 'Preview', preview_page_path(@draft) %> - <%= button_to 'Publish', publish_node_path(@node), method: :put, form: { data: { confirm: "Publish this draft?" } } %> + <%= button_to 'Publish', publish_node_path(@node), method: :put, form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> <%= link_to 'Revisions', node_revisions_path(@node) %> <% end %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 963bc37..71dbaa3 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -59,9 +59,9 @@ 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." } } %> + <%= 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." }, class: 'button_to state_changing' } %> <% else %> - <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put %> + <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put, form: { class: 'button_to state_changing' } %> <% end %> <% end %> diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index b875a4f..26a9ff4 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -27,7 +27,7 @@ <%= button_to 'restore', restore_node_revision_path(@node, page), method: :put, - form: { data: { confirm: "Restore this revision?" } } %> + form: { data: { confirm: "Restore this revision?" }, class: 'button_to state_changing' } %> <% end %> -- cgit v1.3 From 7382e3d15afcef7866667c8b238f4935ae9faff7 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:44:27 +0200 Subject: Add class destructive to destructive buttons --- app/views/assets/index.html.erb | 2 +- app/views/users/_user.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'app/views') diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb index 51fc486..e26830b 100644 --- a/app/views/assets/index.html.erb +++ b/app/views/assets/index.html.erb @@ -15,7 +15,7 @@ <%= asset.upload.content_type %> <%= link_to 'Show', asset %> <%= link_to 'Edit', edit_asset_path(asset) %> - <%= button_to 'Destroy', asset, method: :delete, form: { data: { confirm: 'Are you sure?' } } %> + <%= button_to 'Destroy', asset, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } %> <% end %> diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb index ddb7afd..798b82b 100644 --- a/app/views/users/_user.html.erb +++ b/app/views/users/_user.html.erb @@ -9,7 +9,7 @@ <%= button_to "destroy", user_path(user), method: :delete, - form: { data: { confirm: "Do you really want to delete user #{user.login}?" } } %> + form: { data: { confirm: "Do you really want to delete user #{user.login}?" }, class: 'button_to destructive' } %> <% end %> -- cgit v1.3 From cb610cebffae879344d5a8c6bebcc71ca5c04b2a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:45:35 +0200 Subject: Add or fix class names for the link underline rules to match --- app/views/assets/index.html.erb | 2 +- app/views/events/index.html.erb | 2 +- app/views/users/index.html.erb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) (limited to 'app/views') diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb index e26830b..31dae59 100644 --- a/app/views/assets/index.html.erb +++ b/app/views/assets/index.html.erb @@ -5,7 +5,7 @@ <%= will_paginate @assets %> - +
    <% @assets.each do |asset| %> diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index ae4f477..3065efd 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,6 +1,6 @@

    Listing events

    -
    +
    diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 59806db..cf00ef6 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,4 +1,4 @@ -
    Title Start time
    +
    -- cgit v1.3 From 92c3acb860adff26e1b960c275fd9c83fc2cf4ca Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:46:25 +0200 Subject: Fix column count between table header and body --- app/views/events/index.html.erb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'app/views') diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 3065efd..3143a7b 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -9,6 +9,7 @@ + <% @events.each do |event| %> @@ -20,9 +21,7 @@ - - - + <% end %>

    Admins

    Allday Url NodeAction
    <%=h event.allday %> <%=h event.url %> <%= 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' } %><%= 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' } %>
    -- cgit v1.3 From 75d8308202b1621e8b481ac57f340224ec9077ea Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:47:10 +0200 Subject: Fix subheadings in admin wizard view --- app/views/admin/index.html.erb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) (limited to 'app/views') diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index 2741db3..77b45f4 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -1,5 +1,6 @@
    -
    <%= link_to 'Create Update or Pressemitteilung', new_node_path, :only_path => false %>
    +

    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) %>
    @@ -14,7 +15,7 @@
    -

    Recent Changes

    +

    Recent Changes

    @@ -48,7 +49,7 @@
    -

    My Work

    +

    My Work

    @@ -82,7 +83,7 @@
    -

    Current Drafts (<%= @drafts_count %>)

    +

    Current Drafts (<%= @drafts_count %>)

    @@ -116,7 +117,7 @@
    -

    Sitemap

    +

    Sitemap

    -- cgit v1.3 From 3d7ddaf18419c983dd179b50eabe253c3eee8194 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:55:25 +0200 Subject: WIP: dynamic rrule builder --- app/views/events/edit.html.erb | 30 ++++++++++++++++++++++++++++-- public/javascripts/admin_interface.js | 6 +++++- 2 files changed, 33 insertions(+), 3 deletions(-) (limited to 'app/views') diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 5aee501..d37c299 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -18,8 +18,34 @@ <%= f.datetime_select :end_time %>

    - <%= f.label :rrule %>
    - <%= f.text_field :rrule %> + <%= f.label :rrule, "Recurrence" %> +

    +

    + + +

    +
    +

    +

    + <% %w[MO TU WE TH FR SA SU].each do |code| %> + + <% end %> +

    +
    + +

    + + <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %> +

    + Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them. +
    + <%= f.text_field :rrule, id: "event_rrule" %>

    <%= f.label :tag_list, "Tags" %>
    diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 450438f..cb32d5a 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -50,7 +50,11 @@ $(document).ready(function () { if ($("#move_to_search_term").length != 0) { move_to_search.initialize_search(); } - + + if ($("#rrule_builder").length != 0) { + rrule_builder.initialize(); + } + if ($('#recent_changes_toggle').length != 0) { hide_all(); $('#recent_changes_toggle').attr("class", "selected"); -- cgit v1.3 From b034da9fbabb882fbe9f5f3f355442dfa20e5ecf Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:56:11 +0200 Subject: Tuck away metadata section in nodes#edit behind a summary/detail cascade --- app/views/nodes/edit.html.erb | 105 ++++++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 51 deletions(-) (limited to 'app/views') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 4cdb3ae..4693569 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -1,9 +1,7 @@ <% content_for :subnavigation do %> - <%= link_to 'metadata', '#', :id => 'button', :class => "unselected" %> <%= link_to 'Show', @node %> <%= link_to 'Preview', preview_page_path(@draft) %> <%= button_to 'Publish', publish_node_path(@node), method: :put, form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> - <%= link_to 'Revisions', node_revisions_path(@node) %> <% end %>

    @@ -13,67 +11,72 @@
      <% @node.errors.full_messages.each do |msg| %>
    • <%= msg %>
    • <% end %>
    <% end %> - -
    -
    Slug
    -
    - <%= f.text_field( - :staged_slug, :value => @node.staged_slug || @node.slug - ) - %> -
    -
    parent
    -
    - <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> -
    +

    <%= title_for_node(@node) %>

    -
    - <%= f.hidden_field( - :staged_parent_id, - :value => @node.staged_parent_id || @node.parent_id +
    + Metadata (slug, parent, tags, template, author, images) +
    +
    Slug
    +
    + <%= f.text_field( + :staged_slug, :value => @node.staged_slug || @node.slug ) - %> -
    - - <%= fields_for @draft do |d| %> -
    Tags - comma seperated
    -
    <%= text_field_tag :tag_list, @draft.tag_list.join(', ') %>
    + %> +
    -
    Publish at
    -
    <%= d.datetime_select :published_at, :value => @draft.published_at %>
    +
    parent
    +
    + <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> +
    -
    Template
    -
    - <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %> - Set automatically based on how this node was created - change it if needed. -
    +
    + <%= f.hidden_field( + :staged_parent_id, + :value => @node.staged_parent_id || @node.parent_id + ) + %> +
    -
    Author
    -
    <%= d.select :user_id, user_list %>
    + <%= fields_for @draft do |d| %> +
    Tags - comma seperated
    +
    <%= text_field_tag :tag_list, @draft.tag_list.join(', ') %>
    -
    Images
    -
    -
      - <% @draft.assets.images.each do |image| %> -
    • - <%= image_tag(image.upload.url(:thumb)) %> -
    • - <% end %> -
    -
    - image browser +
    Publish at
    +
    <%= d.datetime_select :published_at, :value => @draft.published_at %>
    + +
    Template
    +
    + <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %> + Set automatically based on how this node was created - change it if needed.
    -
    -
      - <% Asset.images.each do |image| %> -
    • <%= image_tag(image.upload.url(:thumb)) %>
    • + +
      Author
      +
      <%= d.select :user_id, user_list %>
      + +
      Images
      +
      +
        + <% @draft.assets.images.each do |image| %> +
      • + <%= image_tag(image.upload.url(:thumb)) %> +
      • <% end %>
      + +
      +
        + <% Asset.images.each do |image| %> +
      • <%= image_tag(image.upload.url(:thumb)) %>
      • + <% end %> +
      +
    -
    - +
    +
    Title
    <%= d.text_field :title %>
    -- cgit v1.3 From b555a3e05c44f21067855b6bbafa70a5e32c9a40 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:56:39 +0200 Subject: Promote page title to headline in nodes#show --- app/views/nodes/show.html.erb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) (limited to 'app/views') diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 71dbaa3..2ab7986 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -4,8 +4,8 @@ <% end %>
    +

    <%= title_for_node(@node) %>

    -
    People
    @@ -128,9 +128,6 @@
    <% end %> -
    Title
    -
    <%= sanitize(@page.title) %>
    -
    Abstract
    <%= sanitize(@page.abstract) %>
    -- cgit v1.3 From 43e7743ebff1b1f41bbb5ae39bcc5bed7d8982e3 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 16:24:05 +0200 Subject: Autosave posts to its own endpoint surface lock loss immediately Previously piggybacked on the main form's action and relied on a .js.erb response to update #flash; now posts to the dedicated autosave route with an explicit _method=put override, and reacts to a 423 by stopping the timer and showing a persistent banner rather than letting the next Save discover the conflict on its own. --- app/views/nodes/edit.html.erb | 2 +- public/javascripts/admin_interface.js | 38 ++++++++++++++++++++++++++++++++--- public/stylesheets/admin.css | 5 +++++ 3 files changed, 41 insertions(+), 4 deletions(-) (limited to 'app/views') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 4693569..1c6cc3a 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -5,7 +5,7 @@ <% end %>
    -<%= form_for(@node) do |f| %> + <%= form_for(@node, html: { data: { autosave_url: autosave_node_path(@node), show_url: node_path(@node) } }) do |f| %> <% if @node.errors.any? %>
      <% @node.errors.full_messages.each do |msg| %>
    • <%= msg %>
    • <% end %>
    diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 41699a1..4107ce8 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -159,13 +159,45 @@ cccms = { page.cached_abstract = elements.abstract.val(); page.cached_body = elements.body.html(); - $("#flash").append(""); - $.post(this.attr("action"), $(this).serialize(), null, "script"); + var data = this.serializeArray().filter(function(field) { + return field.name !== "_method"; + }); + data.push({ name: "_method", value: "put" }); + + $.ajax({ + type: "POST", + url: this.attr("data-autosave-url"), + data: data, + error: function(xhr) { + if (xhr.status === 423) { + clearInterval(cccms.autosave_timer); + cccms.report_lock_lost($("#page_editor > form").attr("data-show-url")); + } + // any other failure: quietly retried on the next tick + } + }); } }; - setInterval('$("#page_editor > form").submitWithAjax()', 7000); + cccms.autosave_timer = setInterval(function() { + $("#page_editor > form").submitWithAjax(); + }, 7000); + }, + + report_lock_lost : function(show_url) { + var $flash = $("#flash"); + if ($flash.length === 0) { + $flash = $("
    ", { id: "flash" }).insertAfter(".admin_content_spacer"); + } + + var $banner = $("", { "class": "warning" }).text( + "This page is now locked by someone else — your changes here can no longer be saved. Copy anything you need, then " + ); + $banner.append($("", { href: show_url }).text("return to the published page")); + $banner.append("."); + + $flash.html($banner); } } diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 0c0779f..7ae374c 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -200,6 +200,11 @@ span#flash_error, span.warning { padding-bottom: 1px; } +span.warning a { + color: inherit; + text-decoration: underline; +} + /* ============================================================ Pagination ============================================================ */ -- cgit v1.3 From 6398042d3e009d54b15ad01faf993b3d97a203c6 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 17:33:26 +0200 Subject: Add a action bar to the node edit view. This is the first application of a proposed new layout --- app/views/nodes/edit.html.erb | 45 +++++++++++++++++++++++++++++-------------- public/stylesheets/admin.css | 8 ++++++++ 2 files changed, 39 insertions(+), 14 deletions(-) (limited to 'app/views') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 1c6cc3a..feba92a 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -1,9 +1,3 @@ -<% content_for :subnavigation do %> - <%= link_to 'Show', @node %> - <%= link_to 'Preview', preview_page_path(@draft) %> - <%= button_to 'Publish', publish_node_path(@node), method: :put, form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> -<% end %> -
    <%= form_for(@node, html: { data: { autosave_url: autosave_node_path(@node), show_url: node_path(@node) } }) do |f| %> <% if @node.errors.any? %> @@ -14,6 +8,23 @@

    <%= 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)
    @@ -38,12 +49,12 @@ %>
    - <%= fields_for @draft do |d| %> + <%= fields_for @page do |d| %>
    Tags - comma seperated
    -
    <%= text_field_tag :tag_list, @draft.tag_list.join(', ') %>
    +
    <%= text_field_tag :tag_list, @page.tag_list.join(', ') %>
    Publish at
    -
    <%= d.datetime_select :published_at, :value => @draft.published_at %>
    +
    <%= d.datetime_select :published_at, :value => @page.published_at %>
    Template
    @@ -52,12 +63,15 @@
    Author
    -
    <%= d.select :user_id, user_list %>
    +
    + <%= d.select :user_id, user_list, + :selected => @page.user_id || @node.draft&.user_id || @node.head&.user_id %> +
    Images
    -
      - <% @draft.assets.images.each do |image| %> +
        + <% @page.assets.images.each do |image| %>
      • <%= image_tag(image.upload.url(:thumb)) %>
      • @@ -86,8 +100,11 @@
        Body
        <%= d.text_area :body, :class => 'with_editor' %>
        + <% end %> -
        <%= d.submit 'save' %>
        +
        + <%= f.submit 'Save Draft' %> + <%= f.submit 'Save + Unlock + Exit' %> +
        <% end %> -<% end %>
    diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 7ae374c..ceacf17 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -165,6 +165,14 @@ input[type=radio] { margin-bottom: 40px; } +#node_action_bar { + margin-bottom: 1rem; +} + +#node_action_bar > * { + margin-right: 0.5rem; +} + /* ============================================================ Flash / notices ============================================================ */ -- 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/views') 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 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/views') 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 332ffbeed90cf07342925b82ed3fda1762346be2 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 02:25:56 +0200 Subject: Restyle revisions#index: sticky diff bar, computation button style, pre-selected defaults Adds a fourth button category, .computation -- teal, matching Preview's existing color -- for actions that compute a view without changing state, the first case in the app that didn't fit reversible/state-changing/ destructive. Diff revisions now starts disabled and only enables once two distinct revisions are selected, matching the disabled-state pattern already used for Unlock + Back. The header row is wrapped in a real
    (previously flat inside the table alongside data rows) so a sticky diff bar can sit above the fold on long revision lists -- selecting a comparison pair no longer requires scrolling back up to find the button, or back down to confirm what's selected, both surfaced live via one shared JS function. The two most recent revisions are pre-selected by default, covering the most common comparison (current against previous) with no extra clicks. Deliberately scoped to numbered revisions only -- comparing draft/head or autosave/draft was raised and set aside, since neither exists as a row in this table by design and extending the comparison to them is a separate feature, not a default on this one. Also drops the dead "Edit" subnav link, redundant with nodes#show's Status section covering the same action with state-aware labeling this static link could never have. --- app/views/revisions/index.html.erb | 100 +++++++++++++++++++++++-------------- public/stylesheets/admin.css | 66 ++++++++++++++++++------ 2 files changed, 114 insertions(+), 52 deletions(-) (limited to 'app/views') diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index 26a9ff4..a6a981a 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -1,46 +1,70 @@ -<% content_for :subnavigation do %> - <%= link_to 'Edit', edit_node_path(@node) %> -<% end %> -

    Revisions for Node: <%= @node.unique_name %>

    -
    - - - - - - - - - - -<% (@pages || @node.pages.all).reverse.each do |page| %> - - - - - - - - - - -<% end %> - - - +
    FirstLastRev.TitleEditorDate
    <%= radio_button_tag :start_revision, page.revision %><%= radio_button_tag :end_revision, page.revision %><%= page.revision %><%= page.title %><%= page.editor.try(:login) %><%= page.updated_at %><%= link_to 'show', node_revision_path(@node, page) %> - <%= button_to 'restore', restore_node_revision_path(@node, page), - method: :put, - form: { data: { confirm: "Restore this revision?" }, class: 'button_to state_changing' } %> -
    - <%= button_to 'Diff revisions', diff_node_revisions_path(@node), - method: :post, - form: { id: 'diff_form' } %> -
    + + + + + + + + + + + + + + + + + <% pages = (@pages || @node.pages.all).reverse %> + <% pages.each_with_index do |page, index| %> + + + + + + + + + + + <% end %> +
    FirstLastRev.TitleEditorDate
    + <%= button_to 'Diff revisions', diff_node_revisions_path(@node), + method: :post, + form: { id: 'diff_form', class: 'button_to computation' }, + disabled: true %> + +
    <%= radio_button_tag :start_revision, page.revision, index == 1 %><%= radio_button_tag :end_revision, page.revision, index == 0 %><%= page.revision %><%= page.title %><%= page.editor.try(:login) %><%= page.updated_at %><%= link_to 'show', node_revision_path(@node, page) %> + <%= button_to 'restore', restore_node_revision_path(@node, page), + method: :put, + form: { data: { confirm: "Restore this revision?" }, class: 'button_to state_changing' } %> +
    - -
    -

    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 cdf861a23233afb955b3c722a73d9e6ac8de3942 Mon Sep 17 00:00:00 2001
    From: erdgeist 
    Date: Fri, 10 Jul 2026 00:44:22 +0200
    Subject: Fix stale assertions and escaped body in revisions#show
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    The table-based markup revisions#show's own test was asserting
    against (, ) was retired when this view moved to the
    node_description/node_content pattern; updated the assertions to
    match. Found in the same pass: @page.body had no raw(), so any real
    markup in a revision's body rendered as escaped entities on this
    page — same bug class as revisions#diff, just not yet fixed here.
    ---
     app/views/revisions/show.html.erb             |  2 +-
     test/controllers/revisions_controller_test.rb | 24 ++++++++++++++++++------
     2 files changed, 19 insertions(+), 7 deletions(-)
    
    (limited to 'app/views')
    
    diff --git a/app/views/revisions/show.html.erb b/app/views/revisions/show.html.erb
    index 4148096..92d959b 100644
    --- a/app/views/revisions/show.html.erb
    +++ b/app/views/revisions/show.html.erb
    @@ -33,6 +33,6 @@
         
    <%= @page.abstract %>
    Body
    -
    <%= @page.body %>
    +
    <%= raw @page.body %>
    diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index e2fc976..46fc220 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -28,21 +28,33 @@ class RevisionsControllerTest < ActionController::TestCase assert_response :success assert_select ".revision", 2 end - - test "showing one revision" do + + test "showing one revision" do login_as :quentin get :show, params: { :node_id => @node.id, :id => @node.pages.last.id } assert_response :success - assert_select "strong", "Body" - assert_select "td", {:count => 1, :text => "second"} + assert_select ".node_description", "Body" + assert_select ".node_content", {:count => 1, :text => "second"} end - + + test "showing a revision renders real markup in the body, not escaped entities" do + login_as :quentin + node = Node.root.children.create!(:slug => "show_markup_test") + draft = node.draft + draft.body = "

    Hello

    " + node.publish_draft! + + get :show, params: { :node_id => node.id, :id => node.head.id } + assert_response :success + assert_select ".node_content h3", "Hello" + end + test "diffing two revisions" do login_as :quentin post( :diff, params: { :node_id => @node.id, - :start_revision => @node.pages.first.revision, + :start_revision => @node.pages.first.revision, :end_revision => @node.pages.last.revision } ) -- 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/views') 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/views') 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 b928656088ef14e7d004de726df0d1cd70d1cdba Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 03:19:47 +0200 Subject: Move link to full revision list out of summary --- app/views/nodes/show.html.erb | 2 +- public/stylesheets/admin.css | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) (limited to 'app/views') diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 8b9e98b..5756649 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -132,7 +132,6 @@
    <%= pluralize(@node.pages.count, 'revision', 'revisions') %> - · <%= link_to 'full history (diff / restore)', node_revisions_path(@node) %>
      <% @node.pages.order(:revision).each do |page| %> @@ -140,6 +139,7 @@ <% end %>
    +
    Tags
    diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1bb6cf4..e4bdac4 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -65,6 +65,14 @@ select { border: 1px solid #989898; } +summary { + cursor: pointer; +} + +summary:hover { + background-color: #f7f7f7; +} + input[type=password] { border: 1px solid #989898; } @@ -665,9 +673,9 @@ form.button_to button[type="submit"] { 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. */ +.revisions_full_history_link { + margin-bottom: 0; +} /* Layout only -- the at-rest visibility (wavy underline) for these links comes from the scoped rule in Base elements above. */ -- cgit v1.3 From cf730b751b590ca80695492f5d62c4fb43efb4d3 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 03:40:48 +0200 Subject: Ensure that comparison view toggle is there for all diff modes --- app/views/revisions/diff.html.erb | 17 ++++++++++++++++- public/stylesheets/admin.css | 1 + test/controllers/revisions_controller_test.rb | 9 +++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) (limited to 'app/views') diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index 490cf17..d70503c 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -9,13 +9,28 @@ against <%= describe_page_reference(params[:end_revision]) %>

    +

    + View: + <% if @diff_view == :inline %> + Inline + <% else %> + <%= link_to 'Inline', diff_node_revisions_path(@node, start_revision: params[:start_revision], end_revision: params[:end_revision], view: 'inline') %> + <% end %> + · + <% if @diff_view == :side_by_side %> + Side by side + <% else %> + <%= link_to 'Side by side', diff_node_revisions_path(@node, start_revision: params[:start_revision], end_revision: params[:end_revision], view: 'side_by_side') %> + <% end %> +

    + <% 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) %> + <%= hidden_field_tag :view, @diff_view %> <%= submit_tag 'Diff' %> <% end %> <% else %> diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 56470bc..510e3cc 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -32,6 +32,7 @@ table.user_table a, table.revisions_table a, .add_child_links a, .standalone_action_bar a, +.diff_view_toggle a, div#draft_list table td.actions a { text-decoration: underline; -webkit-text-decoration-style: wavy; diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index 162e6f1..bf92c5b 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -139,4 +139,13 @@ class RevisionsControllerTest < ActionController::TestCase assert_select "form.computation input[type=hidden][name=start_revision]" assert_select "form.computation input[type=hidden][name=end_revision]" end + + test "the view toggle is available even when comparing named layers" 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 "a", "Side by side" + end end -- cgit v1.3 From 30ed9fd9a8bffb44e6ab91dfedb8c0e33837769a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 04:41:54 +0200 Subject: Include assets, tags and template in diff between revisions --- app/models/page.rb | 33 ++++++++++++-------- app/views/revisions/diff.html.erb | 27 +++++++++++++++++ public/stylesheets/admin.css | 11 +++++++ test/controllers/revisions_controller_test.rb | 13 ++++++++ test/models/page_test.rb | 43 +++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 13 deletions(-) (limited to 'app/views') diff --git a/app/models/page.rb b/app/models/page.rb index ea04cd6..740d42e 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -176,19 +176,26 @@ class Page < ApplicationRecord 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 + text_diffs = + 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 + + text_diffs.merge( + tags: { added: tag_list.to_a - other.tag_list.to_a, removed: other.tag_list.to_a - tag_list.to_a }, + template_name: { from: other.template_name, to: template_name, changed: template_name != other.template_name }, + assets: { added: assets.to_a - other.assets.to_a, removed: other.assets.to_a - assets.to_a } + ) end def public? diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d70503c..b9ce6bd 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -85,4 +85,31 @@

    Body

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

    Tags

    + <% if @diff[:tags][:added].empty? && @diff[:tags][:removed].empty? %> +

    No change.

    + <% else %> +
      + <% @diff[:tags][:added].each do |tag| %>
    • <%= tag %>
    • <% end %> + <% @diff[:tags][:removed].each do |tag| %>
    • <%= tag %>
    • <% end %> +
    + <% end %> + +

    Template

    + <% if @diff[:template_name][:changed] %> +

    <%= @diff[:template_name][:from] || '(none)' %> <%= @diff[:template_name][:to] || '(none)' %>

    + <% else %> +

    No change.

    + <% end %> + +

    Assets

    + <% if @diff[:assets][:added].empty? && @diff[:assets][:removed].empty? %> +

    No change.

    + <% else %> +
      + <% @diff[:assets][:added].each do |asset| %>
    • <%= asset.upload_file_name %>
    • <% end %> + <% @diff[:assets][:removed].each do |asset| %>
    • <%= asset.upload_file_name %>
    • <% end %> +
    + <% end %>
    diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index e04499d..7b39c72 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -549,6 +549,17 @@ table.revisions_table tr:hover { cursor: help; } +.diff_unchanged { + color: #969696; + font-style: italic; +} + +.diff_set_list { + list-style: none; + padding-left: 0; + margin: 0; +} + 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 bf92c5b..53927b4 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -148,4 +148,17 @@ class RevisionsControllerTest < ActionController::TestCase assert_response :success assert_select "a", "Side by side" end + + test "diffing two revisions also shows tag, template, and asset changes" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.draft.tag_list = "update" + @node.draft.save! + + post(:diff, params: { :node_id => @node.id, :start_revision => @node.pages.first.revision, :end_revision => @node.pages.last.revision }) + assert_response :success + assert_select "h3", "Tags" + assert_select "h3", "Template" + assert_select "h3", "Assets" + end end diff --git a/test/models/page_test.rb b/test/models/page_test.rb index 3868a53..bcddc89 100644 --- a/test/models/page_test.rb +++ b/test/models/page_test.rb @@ -228,4 +228,47 @@ class PageTest < ActiveSupport::TestCase assert_equal 2, fragment.css('ins.diff_structural').length assert_match "der Zugang erfolgt über den Hinterhof.", fragment.text end + + test "diff_against reports tag and template changes" do + n = Node.root.children.create! :slug => "field_diff_test" + d = n.find_or_create_draft @user1 + d.tag_list = "update" + d.template_name = "standard_template" + d.save! + n.publish_draft! + + d2 = n.find_or_create_draft @user1 + d2.tag_list = "update, pressemitteilung" + d2.template_name = "title_only" + d2.save! + + diff = d2.diff_against(n.head) + + assert_equal ["pressemitteilung"], diff[:tags][:added] + assert_equal [], diff[:tags][:removed] + assert diff[:template_name][:changed] + assert_equal "standard_template", diff[:template_name][:from] + assert_equal "title_only", diff[:template_name][:to] + end + + test "diff_against reports added and removed assets by filename" do + n = Node.root.children.create! :slug => "asset_diff_test" + d = n.find_or_create_draft @user1 + d.save! + n.publish_draft! + + kept_asset = Asset.create!(:upload_file_name => "kept.png", :upload_content_type => "image/png", :upload_file_size => 1) + removed_asset = Asset.create!(:upload_file_name => "removed.pdf", :upload_content_type => "application/pdf", :upload_file_size => 1) + n.head.update_assets([kept_asset.id, removed_asset.id]) + + d2 = n.find_or_create_draft @user1 + added_asset = Asset.create!(:upload_file_name => "added.png", :upload_content_type => "image/png", :upload_file_size => 1) + d2.update_assets([kept_asset.id, added_asset.id]) + d2.save! + + diff = d2.diff_against(n.head) + + assert_equal [added_asset], diff[:assets][:added] + assert_equal [removed_asset], diff[:assets][:removed] + end end -- cgit v1.3 From 0571884ea3228a56c6b7e0e084afda2770ee34d9 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 04:43:23 +0200 Subject: Add backlink to node from revisions#index --- app/views/revisions/index.html.erb | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'app/views') diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index 58c08b7..4680ae2 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -1,5 +1,9 @@

    Revisions for Node: <%= @node.unique_name %>

    +

    + <%= link_to 'Back to node', node_path(@node) %> +

    + -- cgit v1.3 From c9401e45433ea45b46f9a8faf1e7e537e4683244 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 18:07:15 +0200 Subject: Fix rrule/url column overflow on events#index Long RRULEs previously overflowed their column with no wrap point; now escaped and rendered with a after each semicolon, so a long rule wraps at a clause boundary instead of running off the table. Deliberately not truncated -- a cut-off RRULE's trailing clause (BYDAY, BYMONTH, etc.) is usually the most specific part. The url column is now a real link, truncated with an ellipsis at a fixed width -- deliberately no tooltip, since hovering a real link already shows the full address in the browser's own status bar. rrule_with_break_opportunities splits on the raw string's own semicolons before escaping each piece, not after -- escaping first and searching the result for semicolons also matches the ones inside </> entities, corrupting anything containing a literal < or >. --- app/helpers/events_helper.rb | 11 +++++++++++ app/views/events/index.html.erb | 8 ++++++-- public/stylesheets/admin.css | 15 +++++++++++++++ test/models/helpers/events_helper_test.rb | 21 +++++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) (limited to 'app/views') diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 8a9a878..5e84f53 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,2 +1,13 @@ +require 'cgi' + module EventsHelper + # Insert a zero-width break opportunity after each semicolon, so a long + # RRULE can wrap at a clause boundary instead of overflowing its column. + # Deliberately , not ellipsis -- unlike a URL, an RRULE's trailing + # characters (BYDAY, BYMONTH, etc.) are usually the most specific part, + # and truncating them would hide exactly the wrong end of the string. + def rrule_with_break_opportunities(rrule) + return "" if rrule.blank? + raw(rrule.split(';', -1).map { |part| CGI.escapeHTML(part) }.join(';')) + end end diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 8127e3a..3d953d5 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -22,9 +22,13 @@ - + - + diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1a81728..4723f50 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -519,6 +519,21 @@ table.revisions_table tr:hover { background-color: #f1f1f1; } +.events_table .rrule_text { + display: inline-block; + max-width: 300px; + overflow-wrap: break-word; +} + +.events_table .truncate { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + #diffview del { background: #ffd7d5; color: #82071e; diff --git a/test/models/helpers/events_helper_test.rb b/test/models/helpers/events_helper_test.rb index 2e7567e..0486b3e 100644 --- a/test/models/helpers/events_helper_test.rb +++ b/test/models/helpers/events_helper_test.rb @@ -1,4 +1,25 @@ require 'test_helper' class EventsHelperTest < ActionView::TestCase + test "rrule_with_break_opportunities inserts a break opportunity after each semicolon" do + result = rrule_with_break_opportunities("FREQ=MONTHLY;BYMONTH=1,2,3;BYDAY=-1TH") + assert_equal "FREQ=MONTHLY;BYMONTH=1,2,3;BYDAY=-1TH", result + assert result.html_safe? + end + + test "rrule_with_break_opportunities escapes HTML-significant characters" do + result = rrule_with_break_opportunities("FREQ=WEEKLY;BYDAY= diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 5be5d57..0e70845 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -201,7 +201,7 @@ parent_search = { clearTimeout(parent_search.path_timeout); parent_search.path_timeout = setTimeout(function() { - $.get("/nodes/parameterize_preview", { title: title }, function(slug) { + $.get(PARAMETERIZE_PREVIEW_URL, { title: title }, function(slug) { $("#resulting_path").text(window.location.origin + "/" + (prefix ? prefix + "/" : "") + slug); }); }, 300); -- 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/views') 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 %> +
    <%= link_to event.display_title, event %> <%=h event.start_time %> <%=h event.end_time %><%=h event.rrule %><%= rrule_with_break_opportunities(event.rrule) %> <%=h event.allday %><%=h event.url %> + <% if event.url.present? %> + <%= link_to event.url, event.url %> + <% end %> + <%= event.node ? link_to(event.node_id, node_path(event.node)) : '' %> <%= link_to 'edit', edit_event_path(event) %>
    + + + + + + + + <% @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/views') 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/views') 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( - "
    " - ); - } - $('#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 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/views') 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/views') 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 848ce18303f927bfadaf0965a769cae2c62492a0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 23:43:02 +0200 Subject: Normalize action-button wording and add icons app-wide "New X" becomes "Create X" throughout (users, events, assets, menu items, nodes), matching the verb-first pattern the dashboard's own signposts already established, with a shared plus icon rather than a document-flavored one that only made sense next to "post". The "Destroy"/"destroy"/"Delete" family is normalized to "Destroy" everywhere, with a shared trash icon; occurrences#index and pages#index also pick up the destructive button class they'd been silently missing. Filter and Search convert from submit_tag to button_tag, the only way either can hold an icon alongside its label. Edit and the node editor's three dynamic labels (Continue Editing / Edit Draft / Lock + Edit) share one icon without touching their wording -- unlike Destroy's family, the state nuance in the text is real information, not just inconsistent phrasing. --- app/views/assets/index.html.erb | 8 ++++++-- app/views/assets/show.html.erb | 4 +++- app/views/events/edit.html.erb | 6 ++++-- app/views/events/index.html.erb | 8 ++++++-- app/views/events/show.html.erb | 8 ++++++-- app/views/events/without_node.html.erb | 4 +++- app/views/menu_items/index.html.erb | 11 +++++++---- app/views/nodes/_node_list.html.erb | 4 +++- app/views/nodes/chapters.html.erb | 6 ++++-- app/views/nodes/edit.html.erb | 8 +++++--- app/views/nodes/index.html.erb | 4 +++- app/views/nodes/show.html.erb | 15 ++++++++++----- app/views/occurrences/index.html.erb | 4 +++- app/views/pages/index.html.erb | 4 +++- app/views/revisions/diff.html.erb | 10 ++++++---- app/views/users/_user.html.erb | 7 ++++--- app/views/users/index.html.erb | 8 ++++++-- app/views/users/show.html.erb | 4 +++- test/controllers/users_controller_test.rb | 2 +- 19 files changed, 86 insertions(+), 39 deletions(-) (limited to 'app/views') diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb index 6591091..8c35561 100644 --- a/app/views/assets/index.html.erb +++ b/app/views/assets/index.html.erb @@ -1,6 +1,8 @@

    Assets

    -<%= link_to 'New asset', new_asset_path, class: 'action_button' %> +<%= link_to new_asset_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create asset +<% end %> <%= will_paginate @assets %> @@ -20,7 +22,9 @@ <%= asset.upload.content_type %> <%= link_to 'Show', asset %> <%= link_to 'Edit', edit_asset_path(asset) %> - <%= button_to 'Destroy', asset, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } %> + <%= button_to asset, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %> <% end %> diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb index 0286678..5717dd9 100644 --- a/app/views/assets/show.html.erb +++ b/app/views/assets/show.html.erb @@ -6,7 +6,9 @@
    - <%= link_to 'Edit', edit_asset_path(@asset), class: 'action_button' %> + <%= link_to edit_asset_path(@asset), class: 'action_button' do %> + <%= icon("edit", library: "tabler", "aria-hidden": true) %> Edit + <% end %>
    <%= link_to 'Back', assets_path %> diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 45b084f..b6564a4 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -2,8 +2,10 @@
    <%= link_to 'Back', safe_return_to(params[:return_to] || events_path) %> - <%= button_to 'Destroy', event_path(@event), method: :delete, - form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } %> + <%= button_to event_path(@event), method: :delete, + form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %>
    <%= form_for(@event) do |f| %> diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 3d953d5..dde897e 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,7 +1,11 @@

    Events

    -<%= link_to 'New event', new_event_path, class: 'action_button' %> -<%= link_to 'View events without a page →', without_node_events_path %> +
    + <%= link_to new_event_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create event + <% end %> + <%= link_to 'View events without a page →', without_node_events_path %> +
    <%= will_paginate @events %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 5812228..ac084c9 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -11,10 +11,14 @@
    <% end %>
    - <%= link_to 'Edit', edit_event_path(@event, return_to: request.path), class: 'action_button' %> + <%= link_to edit_event_path(@event, return_to: request.path), class: 'action_button' do %> + <%= icon("edit", library: "tabler", "aria-hidden": true) %> Edit + <% end %>
    - <%= button_to 'Destroy', event_path(@event), method: :delete, form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } %> + <%= button_to event_path(@event), method: :delete, form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %>
    diff --git a/app/views/events/without_node.html.erb b/app/views/events/without_node.html.erb index 52514ab..bb27173 100644 --- a/app/views/events/without_node.html.erb +++ b/app/views/events/without_node.html.erb @@ -1,7 +1,9 @@

    Events without a node

    <%= link_to '← All events', events_path %> -<%= link_to 'New event', new_event_path, class: 'action_button' %> +<%= link_to new_event_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create event +<% end %> <%= will_paginate @events %> diff --git a/app/views/menu_items/index.html.erb b/app/views/menu_items/index.html.erb index e84199b..591eb68 100644 --- a/app/views/menu_items/index.html.erb +++ b/app/views/menu_items/index.html.erb @@ -1,6 +1,8 @@

    Menu Items

    -<%= link_to 'New menu item', new_menu_item_path, class: 'action_button' %> +<%= link_to new_menu_item_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create menu item +<% end %> <% @menu_items.each do |menu_item| %> @@ -11,9 +13,10 @@ <% end %> diff --git a/app/views/nodes/_node_list.html.erb b/app/views/nodes/_node_list.html.erb index 03f38b4..f7210f1 100644 --- a/app/views/nodes/_node_list.html.erb +++ b/app/views/nodes/_node_list.html.erb @@ -4,7 +4,9 @@ <% 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" %> + <%= button_tag type: "submit", class: "action_button" do %> + <%= icon("search", library: "tabler", "aria-hidden": true) %> Search + <% end %> <% if params[:q].present? || params[:kinds].present? || params[:tags].present? %> <%= link_to "Reset", url_for(controller: params[:controller], action: params[:action]) %> <% end %> diff --git a/app/views/nodes/chapters.html.erb b/app/views/nodes/chapters.html.erb index 939f19c..543bc53 100644 --- a/app/views/nodes/chapters.html.erb +++ b/app/views/nodes/chapters.html.erb @@ -1,9 +1,11 @@

    Chapters

    -<%= form_tag chapters_nodes_path, method: :get do %> +<%= form_tag chapters_nodes_path, method: :get, class: "node_search_form" do %> - <%= submit_tag "Filter", class: "action_button" %> + <%= button_tag type: "submit", class: "action_button" do %> + <%= icon("filter", library: "tabler", "aria-hidden": true) %> Filter + <% end %> <% end %> <%= render 'node_list' %> diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 13b78fc..1ac05e0 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -6,9 +6,11 @@ disabled: @node.autosave.present? %> <% if @node.autosave || (@node.draft && @node.head) %> - <%= 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' } %> + <%= button_to revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + <%= @node.draft && !@node.autosave ? "Destroy Draft" : "Discard Autosave" %> + <% end %> <% if pair = @node.available_layer_pairs.find { |p| p.include?(:autosave) } %> <%= button_to 'What changed?', diff_node_revisions_path(@node), diff --git a/app/views/nodes/index.html.erb b/app/views/nodes/index.html.erb index 2688ea1..3bb251c 100644 --- a/app/views/nodes/index.html.erb +++ b/app/views/nodes/index.html.erb @@ -1,7 +1,9 @@

    Nodes

    - <%= link_to 'New node', new_node_path, class: 'action_button' %> + <%= link_to new_node_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create node + <% end %> 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.
    diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 07cb5d1..6e20258 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -25,7 +25,10 @@ <% 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" %> + <%= link_to edit_node_path(@node), class: "action_button" do %> + <%= icon("edit", library: "tabler", "aria-hidden": true) %> + <%= @node.autosave ? "Continue Editing" : (@node.draft ? "Edit Draft" : "Lock + Edit") %> + <% end %> <% if !@node.draft && !@node.autosave %> Nothing pending — this will start a fresh draft. <% end %> @@ -51,10 +54,12 @@ <% end %> <% if @node.autosave || (@node.draft && @node.head) %>
    - <%= 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' } %> + <%= button_to revert_node_path(@node), method: :put, + params: { return_to: request.path }, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + <%= @node.draft && !@node.autosave ? "Destroy Draft" : "Discard Autosave" %> + <% end %>
    <% end %> <% end %> diff --git a/app/views/occurrences/index.html.erb b/app/views/occurrences/index.html.erb index 82818d0..aa084ed 100644 --- a/app/views/occurrences/index.html.erb +++ b/app/views/occurrences/index.html.erb @@ -18,7 +18,9 @@ - + <% end %> diff --git a/app/views/pages/index.html.erb b/app/views/pages/index.html.erb index 05c7311..da67871 100644 --- a/app/views/pages/index.html.erb +++ b/app/views/pages/index.html.erb @@ -12,7 +12,9 @@ <%=h page.title %> <%= link_to 'Show', content_path(:page_path => page.node.unique_path) %> <%= link_to 'Edit', edit_page_path(page) %> - <%= button_to 'Destroy', page, method: :delete, form: { data: { confirm: 'Are you sure?' } } %> + <%= button_to page, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %> <% end %> diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index b9ce6bd..5638b7a 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -49,10 +49,12 @@ <% 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, - params: { return_to: request.fullpath }, - form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <%= button_to revert_node_path(@node), method: :put, + params: { return_to: request.fullpath }, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + <%= @node.draft && !@node.autosave ? "Destroy Draft" : "Discard Autosave" %> + <% end %> <% end %>

    <% end %> diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb index 798b82b..3a88f5e 100644 --- a/app/views/users/_user.html.erb +++ b/app/views/users/_user.html.erb @@ -7,9 +7,10 @@ <%= link_to "edit", edit_user_path(user) %> - <%= button_to "destroy", user_path(user), - method: :delete, - form: { data: { confirm: "Do you really want to delete user #{user.login}?" }, class: 'button_to destructive' } %> + <%= button_to user_path(user), method: :delete, + form: { data: { confirm: "Do you really want to destroy user #{user.login}?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %> <% end %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 46e958b..4f5266d 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,5 +1,7 @@

    Admins

    -<%= link_to 'New admin user', new_user_path(admin: true), class: 'action_button' %> +<%= link_to new_user_path(admin: true), class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create admin user +<% end %> @@ -11,7 +13,9 @@
    Login

    Users

    -<%= link_to 'New user', new_user_path, class: 'action_button' %> +<%= link_to new_user_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create user +<% end %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index e744efe..70881db 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -6,7 +6,9 @@
    - <%= link_to 'Edit', edit_user_path(@user), class: 'action_button' %> + <%= link_to edit_user_path(@user), class: 'action_button' do %> + <%= icon("edit", library: "tabler", "aria-hidden": true) %> Edit + <% end %>
    diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 5cd5ad4..58f8a86 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -13,7 +13,7 @@ class UsersControllerTest < ActionController::TestCase login_as :aaron get :index assert_response :success - assert_select "button[type=submit]", "destroy" + assert_select "button[type=submit]", "Destroy" assert_select "a", "show" end -- cgit v1.3 From 4faf0da3a36bfdcf354b095a2cf5b297ca09af65 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 00:41:36 +0200 Subject: UI cleanup pass: search overlay position, remaining table forms The search overlay was positioned against the whole viewport (left: 50%, transform: translateX(-50%)) because it sat outside wider text nav it used to center under. Moved inside #navigation, which already has its own positioning context, so it now anchors to the icon nav itself instead of the page center. admin/search_results.html.erb was a bare, unstyled
      concatenating title and unique_name into one string by hand. Restyled to match the byline convention already used everywhere else (title_for_node, a separate link_to_path byline with the trailing arrow), and results now link to the node's admin show page rather than straight to edit, matching every other node list in the app instead of being the one exception. menu_items#new and sessions#new (the login page) were the last two views still using the pre-node_content/node_description table-based label/field layout -- found via a grep for the old td.description marker, not guessed at. Converting menu_items#new surfaced a real, separate gap: its input fields have never had a width rule, since the existing 690px rule only applies to a few specifically-named field IDs or anything inside #page_editor, neither of which applies here. Same likely gap probably exists on other simple CRUD forms (users/events/assets/occurrences/pages) -- not fixed here, flagged for its own pass rather than guessed at blind. nodes#new's Create button was pushed to the far right of the page by one stray class (node_content right) no sibling create form carries -- not a family-wide inconsistency, one extra class in one file. --- app/views/admin/search_results.html.erb | 11 +++++---- app/views/layouts/admin.html.erb | 17 +++++++------ app/views/menu_items/new.html.erb | 42 +++++++++++++-------------------- app/views/nodes/new.html.erb | 2 +- app/views/sessions/new.html.erb | 31 +++++++++--------------- public/stylesheets/admin.css | 21 ++++++++++++++--- 6 files changed, 61 insertions(+), 63 deletions(-) (limited to 'app/views') diff --git a/app/views/admin/search_results.html.erb b/app/views/admin/search_results.html.erb index f8aece0..a757197 100644 --- a/app/views/admin/search_results.html.erb +++ b/app/views/admin/search_results.html.erb @@ -1,7 +1,10 @@

      Search Results

      -
        -<%- @results.each do |result| %> -
      • <%= link_to "#{result.title} (#{result.unique_name})", edit_node_path(result) %>
      • -<% end %> +
          + <% @results.each do |result| %> +
        • + <%= link_to title_for_node(result), node_path(result) %> + <%= link_to_path("#{result.unique_name} ↗", result.unique_name) %> +
        • + <% end %>
        diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 00d7075..61bb5c3 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -27,6 +27,14 @@ +
        <% if flash[:notice].present? || flash[:error].present? %> @@ -50,14 +58,5 @@
        - - diff --git a/app/views/menu_items/new.html.erb b/app/views/menu_items/new.html.erb index 223cb8e..8957293 100644 --- a/app/views/menu_items/new.html.erb +++ b/app/views/menu_items/new.html.erb @@ -1,30 +1,20 @@

        Add Menu Item

        <%= form_for @menu_item do |f| %> -
    Login
    - - - - - - - - - - - - - - - - - - - - - -
    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 'Create' %>
    +
    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 'Create' %>
    <% end %> diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index bb7e078..b63a606 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -46,7 +46,7 @@
    -
    <%= submit_tag "Create" %>
    +
    <%= submit_tag "Create" %>
    <% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 3c10303..23952b7 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,24 +1,15 @@
    <%= form_tag session_path do -%> - - - - - - - - - - - - - - - - - -
    - <%= flash[:error] %>  -
    Login<%= text_field_tag 'login', @login %>
    Password<%= password_field_tag 'password', nil %>
    <%= submit_tag 'log in' %>
    + <% if flash[:error] %> +
    <%= flash[:error] %>
    + <% end %> + +
    Login
    +
    <%= text_field_tag 'login', @login %>
    + +
    Password
    +
    <%= password_field_tag 'password', nil %>
    + +
    <%= submit_tag 'log in' %>
    <% end -%>
    diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index c1cc456..2044ab0 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -26,6 +26,7 @@ a:hover { nodes#show, and the dashboard draft list's Show/Revisions links. */ #page_editor a, .dashboard_widget a, +.search_results_list a, #sitemap a, #flash a, div.pagination a, @@ -941,6 +942,10 @@ div#page_editor { @media(min-width:1016px) { input#tag_list, + input#menu_search_term, + input#menu_item_node_id, + input#menu_item_path, + input#menu_item_title, input#node_staged_slug, input#move_to_search_term { width: 690px; @@ -1073,15 +1078,25 @@ div#draft_list table td.actions a { padding-right: 5px; } +.search_results_list { + list-style: none; + margin: 0; + padding: 0; +} + +.search_results_list li { + padding: 0.5rem 0; + border-bottom: 1px solid #f1f1f1; +} + /* ============================================================ Search widgets ============================================================ */ #search_widget { position: absolute; - top: 20px; - left: 50%; - transform: translateX(-50%); + top: 2.75rem; + left: 0; width: min(520px, 90vw); border: 1px solid #000000; -webkit-box-shadow: 3px 3px 5px #b1b1b1; -- cgit v1.3 From 6730af4b33b207a7a1880cef2706e67313a7e5cd Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 01:01:31 +0200 Subject: Fix search widget input width; remove dead navigation CSS The input's width was calc(100% - 70px), a number tuned to leave room for a "Search: " label that no longer needs to exist -- replaced by a placeholder instead, which also removes the fragile arithmetic entirely rather than re-tuning it. Also picked up a proper border-radius and padding, matching the dashboard's own search input. The width bug's actual proximate cause was a stray #navigation div { float: left } left over from the old text nav, interfering with the icon nav's own flex layout. Going through that whole block with the same scrutiny turned up four more rules worth removing, not fixing: a:hover (identical specificity, later in the file, silently winning the cascade since the icon nav landed), and #metadata/#overview_toggle in the remaining selectors were dead for two different, confirmed reasons -- #overview_toggle was the old wizard's toggle bar, gone along with it, and #metadata here was the pre-details-view subnav toggle, retired before this session and unrelated to the current, still-live #metadata id on the metadata-details section in nodes#edit (a confusing reuse that's caused real problems before). --- app/views/layouts/admin.html.erb | 2 +- public/stylesheets/admin.css | 35 +++++------------------------------ 2 files changed, 6 insertions(+), 31 deletions(-) (limited to 'app/views') diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 61bb5c3..5ae363f 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -30,7 +30,7 @@

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