From 51d491a3d67e8c9efde8019ecb8a8e1a8195ec99 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 5 Jul 2026 21:51:15 +0200 Subject: Add erfa/chaostreff node-kind creation conventions lib/ccc_conventions.rb: NODE_KINDS registry replaces the kind-specific branches previously scattered across nodes_controller#create (tags), unique_path-position check). Each kind declares its parent lookup (a Proc - Update's "update"/"press_release" continue to genuinely find-or-create their year-folder via Update.find_or_create_parent; erfa/chaostreff use a plain find_by_unique_name!, since their parent nodes are fixed and a missing one should fail loudly, not be silently created), its tags, and (new) its default template. Node gains a default_template_name column (migration, with backfill for existing update-tree nodes via node.update? - reusing that method rather than re-deriving its unique_path check in raw SQL). Page#set_template now inherits from node.default_template_name, falling back to the old update?-based check only when the column is blank, and only fills in template_name when nothing's already been explicitly chosen - a deliberate change from the previous behavior, which unconditionally overwrote template_name on every save regardless of manual selection. Node#update? itself is unchanged and still used as-is by admin_controller's sitemap filtering - a genuinely different, still valid use of that check. "generic" stays special-cased in the controller, parametrized by params[:parent_id] at request time - doesn't fit "kind implies a fixed lookup" and isn't in the registry. nodes#new's four hardcoded radio buttons and nodes_controller's kind-specific case/when branches are both replaced by iterating/looking up this one registry - adding a new kind now means one new hash entry, not four scattered edits. --- config/routes.rb | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'config/routes.rb') diff --git a/config/routes.rb b/config/routes.rb index a7775b3..cf4733c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,10 @@ Cccms::Application.routes.draw do end resources :nodes do + collection do + get :parameterize_preview + end + member do put :unlock put :publish -- cgit v1.3 From 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 'config/routes.rb') 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 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 'config/routes.rb') 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 087fa345853a103844375892fa0405c1c241ea92 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 16:25:43 +0200 Subject: Route Save and autosave through the new Node methods (addendum) --- config/routes.rb | 1 + 1 file changed, 1 insertion(+) (limited to 'config/routes.rb') diff --git a/config/routes.rb b/config/routes.rb index 26c3d4d..1569a15 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,6 +40,7 @@ Cccms::Application.routes.draw do put :publish put :generate_shared_preview put :revoke_shared_preview + put :autosave end resources :revisions do -- cgit v1.3 From bc03601ee5c7acd4ef012ec4a404bd7b76bceaa0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 17:32:17 +0200 Subject: Add revert!/discard and rebuild nodes#edit around the new hierarchy revert! discards exactly the topmost non-empty layer -- autosave if present, else draft -- and reveals whatever's beneath it, releasing the lock only once nothing is left to protect. Guards against destroying a brand-new, never-published node's only draft, which would violate the (head | draft) invariant every other method here assumes holds; the view's Destroy/Discard button is gated the same way. nodes#edit now calls lock_for_editing! instead of find_or_create_draft, and always displays autosave || draft || head, resurrecting an abandoned session's unsaved content by default with an explicit flash explaining what's shown and how to get back to the last saved version. The view drops content_for :subnavigation entirely: Show becomes "Unlock + Back", Preview stays a plain link, metadata's own
already replaced the old toggle, and Publish moves off this page for good, per the earlier decision to manage the publish lifecycle entirely from nodes#show. Save Draft and Save + Unlock + Exit appear both above and below the form, given the body field alone runs 600px on desktop. --- app/controllers/nodes_controller.rb | 34 ++++++++++----- config/routes.rb | 1 + test/models/node_test.rb | 87 +++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 10 deletions(-) (limited to 'config/routes.rb') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 042963b..6fd000b 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -12,7 +12,8 @@ class NodesController < ApplicationController :destroy, :publish, :unlock, - :autosave + :autosave, + :revert ] def index @@ -59,16 +60,17 @@ class NodesController < ApplicationController end def edit - begin - @draft = @node.find_or_create_draft( current_user ) - rescue LockedByAnotherUser => e - flash[:error] = e.message - if request.referer - redirect_to request.referer || node_path(@node) - else - redirect_to node_path(@node) - end + @node.lock_for_editing!( current_user ) + @page = @node.autosave || @node.draft || @node.head + + if @node.autosave + flash.now[:notice] = + "This page has unsaved changes from a previous session, shown below. " \ + "Save to keep them, or use \"Discard changes\" below to go back to the last saved version." end + rescue LockedByAnotherUser => e + flash[:error] = e.message + redirect_to(request.referer || node_path(@node)) end def update @@ -104,6 +106,18 @@ class NodesController < ApplicationController render plain: "Autosave failed", status: :internal_server_error end + def revert + @node.revert!(current_user) + if @node.draft + redirect_to edit_node_path(@node) + else + redirect_to node_path(@node) + end + rescue LockedByAnotherUser => e + flash[:error] = e.message + redirect_to node_path(@node) + end + def destroy @node.destroy end diff --git a/config/routes.rb b/config/routes.rb index 1569a15..da46e5c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,6 +41,7 @@ Cccms::Application.routes.draw do put :generate_shared_preview put :revoke_shared_preview put :autosave + put :revert end resources :revisions do diff --git a/test/models/node_test.rb b/test/models/node_test.rb index bdf556d..2138c19 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -379,6 +379,93 @@ class NodeTest < ActiveSupport::TestCase assert_nil node.autosave assert_nil node.lock_owner end + + test "revert! is a safe no-op on a fresh node with only a draft" do + node = create_node_with_draft + node.lock_for_editing!(@user1) + + node.revert!(@user1) + node.reload + + assert_not_nil node.draft + assert_equal @user1, node.lock_owner + end + + test "revert! discards an autosave on a fresh node without touching its only draft" do + node = create_node_with_draft + node.lock_for_editing!(@user1) + node.autosave!({ :title => "typing" }, @user1) + + node.revert!(@user1) + node.reload + + assert_nil node.autosave + assert_not_nil node.draft + assert_equal @user1, node.lock_owner + end + + test "revert! does nothing when a published node has no draft or autosave" do + node = create_node_with_published_page + node.lock_for_editing!(@user1) + + node.revert!(@user1) + node.reload + + assert_not_nil node.head + assert_nil node.draft + end + + test "revert! discards a fresh autosave and releases the lock when no draft exists" do + node = create_node_with_published_page + node.lock_for_editing!(@user1) + node.autosave!({ :title => "in progress" }, @user1) + + node.revert!(@user1) + node.reload + + assert_nil node.autosave + assert_nil node.draft + assert_nil node.lock_owner + end + + test "revert! destroys an existing draft and releases the lock" do + node = create_node_with_published_page + head_title = node.head.title + node.lock_for_editing!(@user1) + node.autosave!({ :title => "second version" }, @user1) + node.save_draft!(@user1) + + node.revert!(@user1) + node.reload + + assert_nil node.draft + assert_equal head_title, node.head.title + assert_nil node.lock_owner + end + + test "revert! discards only the autosave when a draft survives beneath it" do + node = create_node_with_published_page + node.lock_for_editing!(@user1) + node.autosave!({ :title => "second version" }, @user1) + node.save_draft!(@user1) + node.autosave!({ :title => "third version, still typing" }, @user1) + + node.revert!(@user1) + node.reload + + assert_nil node.autosave + assert_not_nil node.draft + assert_equal "second version", node.draft.title + assert_equal @user1, node.lock_owner + end + + test "revert! raises LockedByAnotherUser for a non-owner" do + node = create_node_with_published_page + node.lock_for_editing!(@user1) + + assert_raise(LockedByAnotherUser) { node.revert!(@user2) } + assert_equal @user1, node.reload.lock_owner + end def create_revisions node, count count.times do -- cgit v1.3 From 8310751d4db2854597d6379b24e346c65622972d Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 14:19:36 +0200 Subject: Major resource view cleanup Remove dead pages#index and occurrences resource; fix menu_items#new menu_items#new called menu_item_params, which requires a submitted menu_item key that can never be present on a fresh GET -- new only ever needs a blank record to render against. pages#index and the entire occurrences resource were unmodified Rails scaffold generator output, unlinked from any nav and unreachable except by direct URL. pages#index had no controller action at all (@pages was never assigned); occurrences#index queried every row with no scoping or pagination and its layout referenced a scaffold.css asset that no longer exists in the pipeline. Neither is a real editorial surface -- occurrences are auto-generated from an event's RRULE and were never meant to be browsed as a flat list. Removed rather than repaired. pages#preview and pages#sort_images remain, now as explicit routes rather than under a full resources :pages block. --- app/controllers/menu_items_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/views/assets/index.html.erb | 6 ++-- app/views/assets/show.html.erb | 50 ++++++++++++++++------------- app/views/events/index.html.erb | 4 ++- app/views/menu_items/edit.html.erb | 51 +++++++++++++----------------- app/views/menu_items/index.html.erb | 9 ++---- app/views/nodes/index.html.erb | 5 +++ app/views/occurrences/index.html.erb | 2 +- app/views/pages/index.html.erb | 2 +- app/views/users/edit.html.erb | 54 ++++++++++++++------------------ app/views/users/index.html.erb | 17 +++++----- app/views/users/new.html.erb | 52 ++++++++++++++---------------- app/views/users/show.html.erb | 39 ++++++++++++----------- config/routes.rb | 9 ++---- public/stylesheets/admin.css | 32 +++++++++++++++++++ 16 files changed, 177 insertions(+), 159 deletions(-) (limited to 'config/routes.rb') diff --git a/app/controllers/menu_items_controller.rb b/app/controllers/menu_items_controller.rb index 1b1eb59..314d1ea 100644 --- a/app/controllers/menu_items_controller.rb +++ b/app/controllers/menu_items_controller.rb @@ -14,7 +14,7 @@ class MenuItemsController < ApplicationController end def new - @menu_item = MenuItem.new menu_item_params + @menu_item = MenuItem.new end def create diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f01691f..6572b7a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -15,7 +15,7 @@ class UsersController < ApplicationController end def new - @user = User.new + @user = User.new(admin: params[:admin].present?) end def create diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb index 4301fee..6591091 100644 --- a/app/views/assets/index.html.erb +++ b/app/views/assets/index.html.erb @@ -1,6 +1,6 @@ -<% content_for :subnavigation do %> - <%= link_to 'New asset', new_asset_path %> -<% end %> +

Assets

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

<%= @asset.name %>

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

Listing events

+

Events

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

Edit Menu Item

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

Menu Items

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

Nodes

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

Listing occurrences

+

Occurrences

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

Listing pages

+

Pages

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

Admins

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

Admins

Login

Users

+ +

Users

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

<%= @user.login %>

-

User: <%= @user.login %>

+
+
Actions
+
+
+
+ <%= link_to 'Edit', edit_user_path(@user), class: 'action_button' %> +
+
+
- - - - - - - - - - - - - -
Login<%= @user.login %>
E-Mail<%= @user.email %>
Admin?<%= @user.admin ? "yes" : "no" %>
\ No newline at end of file +
Login
+
<%= @user.login %>
+ +
E-Mail
+
<%= @user.email %>
+ +
Admin?
+
<%= @user.admin ? "yes" : "no" %>
+
+
diff --git a/config/routes.rb b/config/routes.rb index da46e5c..5d68828 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,15 +20,10 @@ Cccms::Application.routes.draw do scope '(:locale)', locale: /de|en/ do resources :tags - resources :occurrences resources :events - resources :pages do - member do - get :preview - put :sort_images - end - end + get 'pages/:id/preview', to: 'pages#preview', as: :preview_page + put 'pages/:id/sort_images', to: 'pages#sort_images', as: :sort_images_page resources :nodes do collection do diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 66f13b8..f00a658 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -640,6 +640,13 @@ form.button_to button[type="submit"] { color: #969696; padding-bottom: 4px; } + +.action_button + .field_hint { + display: inline; + margin-left: 0.5rem; + margin-top: 0; +} + /* Identical declaration block to #search_results p span.result_path / #menu_search_results p span.result_path below -- three independent copies of the same "small gray helper text" style. */ @@ -692,6 +699,7 @@ a.action_button { font-weight: bold; text-decoration: none; color: #000000; + margin-bottom: 1rem; } #page_editor a.action_button:hover, @@ -700,6 +708,11 @@ a.action_button:hover { background-color: #000000; } +#page_editor form input[type=text], +#page_editor form input[type=password] { + padding: 5px; +} + @media(min-width:1016px) { input#tag_list, input#node_staged_slug, @@ -731,6 +744,11 @@ a.action_button:hover { #page_editor #metadata, #page_editor #content, #admin_overview { margin-left: -125px; } + + #page_editor form input[type=text], + #page_editor form input[type=password] { + width: 690px; + } } @media(max-width:1015px) { @@ -773,6 +791,12 @@ a.action_button:hover { font-weight: bold; } + #page_editor form input[type=text], + #page_editor form input[type=password] { + box-sizing: border-box; + width: 100%; + } + input[type=text], textarea { font-size: 1.5rem; } @@ -920,6 +944,14 @@ div#draft_list table td.actions a { cursor: grab; } +#menu_item_list td a { + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-color: #b0b0b0; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + .ui-state-highlight td { height: 20px; } -- cgit v1.3 From 7b57d898da07811bb35ec088cb12505783a55214 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 18:54:39 +0200 Subject: Add an events view for events without node --- app/views/events/without_node.html.erb | 28 ++++++++++++++++++++++++++++ config/routes.rb | 7 ++++++- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 app/views/events/without_node.html.erb (limited to 'config/routes.rb') diff --git a/app/views/events/without_node.html.erb b/app/views/events/without_node.html.erb new file mode 100644 index 0000000..52514ab --- /dev/null +++ b/app/views/events/without_node.html.erb @@ -0,0 +1,28 @@ +

Events without a node

+ +<%= link_to '← All events', events_path %> +<%= link_to 'New event', new_event_path, class: 'action_button' %> + +<%= will_paginate @events %> + + + + + + + + + + + +<% @events.each do |event| %> + + + + + + + + +<% end %> +
TitleStart timeEnd timeAlldayUrl
<%= link_to event.display_title, event %><%=h event.start_time %><%=h event.end_time %><%=h event.allday %><%=h event.url %><%= link_to 'edit', edit_event_path(event) %>
diff --git a/config/routes.rb b/config/routes.rb index 5d68828..c0aef2f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,7 +20,12 @@ Cccms::Application.routes.draw do scope '(:locale)', locale: /de|en/ do resources :tags - resources :events + + resources :events do + collection do + get :without_node + end + end get 'pages/:id/preview', to: 'pages#preview', as: :preview_page put 'pages/:id/sort_images', to: 'pages#sort_images', as: :sort_images_page -- 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 'config/routes.rb') 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 57588a423878016c9981751d50337e37cddea778 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 11 Jul 2026 01:43:09 +0200 Subject: Move all admin routes under /admin Nodes and revisions join assets under the existing /admin scope. admin#index/search/menu_search/conventions moved into the same scope block instead of each manually prefixing "admin"/"admin/...". Route helper names and behavior unchanged -- only the URLs move. --- config/routes.rb | 51 ++++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 25 deletions(-) (limited to 'config/routes.rb') diff --git a/config/routes.rb b/config/routes.rb index da6b626..aebce90 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,43 +30,44 @@ Cccms::Application.routes.draw do get 'pages/:id/preview', to: 'pages#preview', as: :preview_page put 'pages/:id/sort_images', to: 'pages#sort_images', as: :sort_images_page - resources :nodes do - collection do - get :parameterize_preview - end + get 'preview/:token', to: 'shared_previews#show', as: :shared_preview - member do - put :unlock - put :publish - put :generate_shared_preview - put :revoke_shared_preview - put :autosave - put :revert - end + scope '/admin' do + resources :assets - resources :revisions do + resources :nodes do collection do - post :diff - get :diff + get :parameterize_preview end + member do - put :restore + put :unlock + put :publish + put :generate_shared_preview + put :revoke_shared_preview + put :autosave + put :revert end - end - end - get 'preview/:token', to: 'shared_previews#show', as: :shared_preview + resources :revisions do + collection do + post :diff + get :diff + end + member do + put :restore + end + end + end - scope '/admin' do - resources :assets + 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 end match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete match '/login' => 'sessions#new', :as => :login, :via => :get - 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 -- 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 'config/routes.rb') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 6fcd930..ede91ad 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -183,6 +183,39 @@ class NodesController < ApplicationController render plain: slug_for(params[:title]) end + # Filter functions for admin views + def drafts + base = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") + @nodes = index_matching(base) + end + + def recent + base = Node.where( + "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", + Time.now, Time.now - 14.days + ) + @nodes = index_matching(base) + end + + def mine + base = Node.joins(:pages) + .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) + .distinct + @nodes = index_matching(base) + end + + def chapters + @kind_keys = Array(params[:kinds]) & %w[erfa chaostreff] + @kind_keys = %w[erfa chaostreff] if @kind_keys.empty? + tags = @kind_keys.flat_map { |key| CccConventions::NODE_KINDS[key][:tags] } + @nodes = nodes_matching_tags(tags) + end + + def tags + tags = params[:tags].to_s.split(',').map(&:strip).reject(&:blank?) + @nodes = nodes_matching_tags(tags) + end + private def slug_for(title) @@ -214,4 +247,16 @@ class NodesController < ApplicationController config && config[:parent] ? config[:parent].call.id : nil end end + + def nodes_matching_tags(tags) + matching_pages = Page.tagged_with(tags, any: true).reselect(:id) + base = Node.where(head_id: matching_pages).or(Node.where(draft_id: matching_pages)) + index_matching(base) + end + + def index_matching(base_scope) + scope = base_scope.includes(:head, :draft) + scope = scope.merge(Node.editor_search(params[:q])) if params[:q].present? + scope.order("nodes.updated_at desc").paginate(page: params[:page], per_page: 25) + end end diff --git a/app/models/node.rb b/app/models/node.rb index 9cb4840..24f3cd0 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -345,6 +345,28 @@ class Node < ApplicationRecord .distinct end + # This one is for admin-only views, where finding a draft is the point. + # Substring match on whichever of head/draft is present. + def self.editor_search(term) + words = term.to_s.split(/\s+/).reject(&:blank?) + return none if words.empty? + + conditions = [] + binds = {} + + words.each_with_index do |word, i| + key = "term#{i}" + binds[key.to_sym] = "%#{sanitize_sql_like(word)}%" + conditions << "(head_translations.title ILIKE :#{key} OR head_translations.abstract ILIKE :#{key} " \ + "OR draft_translations.title ILIKE :#{key} OR draft_translations.abstract ILIKE :#{key})" + end + + joins("LEFT JOIN page_translations head_translations ON head_translations.page_id = nodes.head_id") + .joins("LEFT JOIN page_translations draft_translations ON draft_translations.page_id = nodes.draft_id") + .where(conditions.join(" AND "), binds) + .distinct + end + protected def lock_for! current_user self.lock_owner = current_user diff --git a/app/views/nodes/_node_list.html.erb b/app/views/nodes/_node_list.html.erb new file mode 100644 index 0000000..03f38b4 --- /dev/null +++ b/app/views/nodes/_node_list.html.erb @@ -0,0 +1,39 @@ +<%= form_tag url_for(controller: params[:controller], action: params[:action]), method: :get, class: "node_search_form" do %> + <% Array(params[:kinds]).each do |kind| %> + <%= hidden_field_tag "kinds[]", kind %> + <% end %> + <%= hidden_field_tag :tags, params[:tags] if params[:tags].present? %> + <%= text_field_tag :q, params[:q], placeholder: "Search title, abstract, body…" %> + <%= submit_tag "Search", class: "action_button" %> + <% if params[:q].present? || params[:kinds].present? || params[:tags].present? %> + <%= link_to "Reset", url_for(controller: params[:controller], action: params[:action]) %> + <% end %> +<% end %> + +<%= will_paginate @nodes %> + + + + + + + + + <% @nodes.each do |node| %> + "> + + + + + + + <% end %> +
IDTitleActionsLocked byRev.
<%= node.id %> +

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

+

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

+
+ <%= link_to 'show', node_path(node) %> + <%= link_to 'edit', edit_node_path(node) %> + <%= link_to 'revisions', node_revisions_path(node) %> + <%= node.lock_owner.login if node.lock_owner %><%= node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY") %>
+<%= will_paginate @nodes %> diff --git a/app/views/nodes/chapters.html.erb b/app/views/nodes/chapters.html.erb new file mode 100644 index 0000000..939f19c --- /dev/null +++ b/app/views/nodes/chapters.html.erb @@ -0,0 +1,9 @@ +

Chapters

+ +<%= form_tag chapters_nodes_path, method: :get do %> + + + <%= submit_tag "Filter", class: "action_button" %> +<% end %> + +<%= render 'node_list' %> diff --git a/app/views/nodes/drafts.html.erb b/app/views/nodes/drafts.html.erb new file mode 100644 index 0000000..6d0830c --- /dev/null +++ b/app/views/nodes/drafts.html.erb @@ -0,0 +1,3 @@ +

Nodes with drafts or autosaves

+ +<%= render 'node_list' %> diff --git a/app/views/nodes/mine.html.erb b/app/views/nodes/mine.html.erb new file mode 100644 index 0000000..fc5680a --- /dev/null +++ b/app/views/nodes/mine.html.erb @@ -0,0 +1,3 @@ +

My work

+ +<%= render 'node_list' %> diff --git a/app/views/nodes/recent.html.erb b/app/views/nodes/recent.html.erb new file mode 100644 index 0000000..3602775 --- /dev/null +++ b/app/views/nodes/recent.html.erb @@ -0,0 +1,3 @@ +

Recently changed

+ +<%= render 'node_list' %> diff --git a/app/views/nodes/tags.html.erb b/app/views/nodes/tags.html.erb new file mode 100644 index 0000000..0ebc6ce --- /dev/null +++ b/app/views/nodes/tags.html.erb @@ -0,0 +1,3 @@ +

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

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

Sitemap

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

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

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

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

+
+ + <% next_level = @sitemap[index + 1]&.last %> + <% if next_level && next_level > level %> + > + + <%= pluralize(@sitemap_descendant_counts[node.id], 'descendant', 'descendants') %> + + <% open_details.push(level) %> + <% end %> +<% end %> +<% open_details.length.times { %>
<% } %> +
diff --git a/config/routes.rb b/config/routes.rb index 2c165d2..bb34bd8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,6 +43,7 @@ Cccms::Application.routes.draw do get :recent get :mine get :chapters + get :sitemap end member do diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb index b420452..352dd3c 100644 --- a/lib/ccc_conventions.rb +++ b/lib/ccc_conventions.rb @@ -1,6 +1,7 @@ module CccConventions ERFA_PARENT_NAME = "club/erfas" CHAOSTREFF_PARENT_NAME = "club/chaostreffs" + SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze NODE_KINDS = { "top_level" => { diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index aa8b288..5c1e489 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -25,6 +25,7 @@ a:hover { body content, node listing tables, the child-creation shortcuts on nodes#show, and the dashboard draft list's Show/Revisions links. */ #page_editor a, +#sitemap a, table.node_table a, table.assets_table a, table.events_table a, @@ -104,6 +105,11 @@ input[type=radio] { width: 100%; box-sizing: border-box; } + + #sitemap details details { + margin-left: 0.75rem; + padding-left: 0.5rem; + } } #metadata, @@ -773,6 +779,40 @@ form.button_to button[type="submit"] { white-space: nowrap; } +.sitemap_node { + padding-bottom: 0.5rem; + margin-bottom: 0.5rem; + border-bottom: 1px solid #ececec; +} + +.sitemap_node h4 { + margin: 0; +} + +.sitemap_node .field_hint { + display: block; + margin: 2px 0 0; +} + +.sitemap_node p { + margin: 2px 0 0; +} + +#sitemap details details { + margin-left: 1.5rem; + border-left: 1px solid #e8e8e8; + padding-left: 0.75rem; +} + +#sitemap summary { + padding: 4px 0; +} + +#sitemap details:not([open]) > summary { + color: #ff9600; + font-weight: bold; +} + /* ============================================================ Page editor / metadata forms ============================================================ */ diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 05cb195..b43d2de 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -575,4 +575,56 @@ class NodesControllerTest < ActionController::TestCase get :chapters assert_select "h1", "Chapters" end + + test "sitemap collapses configured paths but leaves others open" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + erfas.children.create!(:slug => "one_chapter") + other = Node.root.children.create!(:slug => "sitemap_controller_open_test") + other.children.create!(:slug => "sitemap_controller_open_child") + + login_as :quentin + get :sitemap + assert_response :success + + doc = Nokogiri::HTML::DocumentFragment.parse(response.body) + + erfas_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(erfas.unique_name) } + other_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(other.unique_name) } + + erfas_details = erfas_node_div.next_element + other_details = other_node_div.next_element + + assert_equal 'details', erfas_details.name + assert_equal 'details', other_details.name + assert_not erfas_details.key?('open') + assert other_details.key?('open') + end + + test "sitemap shows how many descendants a collapsed branch is hiding" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + erfas.children.create!(:slug => "one_chapter") + erfas.children.create!(:slug => "another_chapter") + + login_as :quentin + get :sitemap + assert_response :success + + doc = Nokogiri::HTML::DocumentFragment.parse(response.body) + erfas_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(erfas.unique_name) } + erfas_details = erfas_node_div.next_element + + assert_equal 'details', erfas_details.name + assert_match "2 descendants", erfas_details.at_css('summary').text + end + + test "sitemap shows Show and Create Child, not Revisions" do + node = Node.root.children.create!(:slug => "sitemap_actions_test") + login_as :quentin + get :sitemap + assert_select ".sitemap_node_actions", :text => /Show/ + assert_select ".sitemap_node_actions", :text => /Create Child/ + assert_select ".sitemap_node_actions", :text => /Revisions/, :count => 0 + end end diff --git a/test/models/helpers/nodes_helper_test.rb b/test/models/helpers/nodes_helper_test.rb index 5d91a88..5ab924f 100644 --- a/test/models/helpers/nodes_helper_test.rb +++ b/test/models/helpers/nodes_helper_test.rb @@ -22,4 +22,15 @@ class NodesHelperTest < ActionView::TestCase page = FakePage.new([]) assert_nil default_event_tag_list(page) end + + test "sitemap_node_open? is false for a configured collapsed path" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + assert_equal false, sitemap_node_open?(erfas) + end + + test "sitemap_node_open? is true for anything not configured as collapsed" do + node = Node.root.children.create!(:slug => "sitemap_open_test") + assert_equal true, sitemap_node_open?(node) + end end -- cgit v1.3 From 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 'config/routes.rb') 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 70653b681d10917b77dced08f577446ced7568f1 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 03:22:37 +0200 Subject: Add RelatedAssetsController: search, attach, detach, reorder Backend for the asset-picker rebuild -- replaces the plan to dump every image asset in the system into a hidden, unfiltered browse panel on every node edit (the actual current behavior, confirmed by reading nodes/edit.html.erb directly) with a small, name-scoped search endpoint plus create/destroy/update for attach, detach, and reorder. No schema change needed -- RelatedAsset already had everything this requires (asset_id, page_id, an acts_as_list position). search excludes assets already attached to the page, keeping results meaningfully small given hundreds of assets total but only a handful per node in practice. create is find_or_create_by! rather than a bare create!, guarding against the same asset being attached twice from two separate search results. update leans on acts_as_list's own insert_at rather than custom position-shifting logic. Node#editable_page (autosave || draft || head) is extracted since this is now its third call site with identical logic -- deliberately not touching nodes#show, which resolves draft || head without autosave on purpose, a different and correct semantic for "current committed state" versus "what's actively being edited." --- app/controllers/related_assets_controller.rb | 50 +++++++++++++ app/models/node.rb | 4 ++ config/routes.rb | 6 ++ test/controllers/related_assets_controller_test.rb | 83 ++++++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 app/controllers/related_assets_controller.rb create mode 100644 test/controllers/related_assets_controller_test.rb (limited to 'config/routes.rb') diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb new file mode 100644 index 0000000..906c02c --- /dev/null +++ b/app/controllers/related_assets_controller.rb @@ -0,0 +1,50 @@ +class RelatedAssetsController < ApplicationController + before_action :login_required + before_action :find_node + + def search + term = params[:q].to_s.strip + if term.blank? + render json: [] + return + end + + attached_ids = @node.editable_page.related_assets.pluck(:asset_id) + results = Asset.images + .where("name ILIKE ?", "%#{term}%") + .where.not(id: attached_ids) + .limit(10) + + render json: results.map { |a| + { id: a.id, name: a.name, thumb_url: a.upload.url(:thumb) } + } + end + + def create + asset = Asset.find(params[:asset_id]) + related = @node.editable_page.related_assets.find_or_create_by!(asset: asset) + + render json: { + id: related.id, + asset_id: asset.id, + name: asset.name, + thumb_url: asset.upload.url(:thumb) + } + end + + def destroy + @node.editable_page.related_assets.find(params[:id]).destroy + head :ok + end + + def update + @node.editable_page.related_assets.find(params[:id]).insert_at(params[:position].to_i) + head :ok + end + + private + + def find_node + @node = Node.find(params[:node_id]) + end +end diff --git a/app/models/node.rb b/app/models/node.rb index 1d0a089..0361c1e 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -328,6 +328,10 @@ class Node < ApplicationRecord unique_path.length == 3 && unique_path[0] == "updates" end + def editable_page + autosave || draft || head + end + # Returns immutable node id for all new nodes so that the atom feed entry ids # stay the same eventhough the slug or positions changes. # Can be removed after a year or so ;) diff --git a/config/routes.rb b/config/routes.rb index 92301e5..5d61bae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,6 +55,12 @@ Cccms::Application.routes.draw do put :revert end + resources :related_assets, only: [:create, :destroy, :update] do + collection do + get :search + end + end + resources :revisions do collection do post :diff diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb new file mode 100644 index 0000000..a3082c2 --- /dev/null +++ b/test/controllers/related_assets_controller_test.rb @@ -0,0 +1,83 @@ +require 'test_helper' + +class RelatedAssetsControllerTest < ActionController::TestCase + test "search finds assets by name, excluding ones already attached" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_search_test") + + attached = Asset.create!(:name => "biometrics-scan", :upload_content_type => "image/png") + node.draft.assets << attached + + Asset.create!(:name => "biometrics-poster", :upload_content_type => "image/png") + Asset.create!(:name => "chaostreff-flyer", :upload_content_type => "image/png") + + get :search, params: { :node_id => node.id, :q => "biometrics" } + + json = JSON.parse(response.body) + names = json.map { |a| a["name"] } + assert_includes names, "biometrics-poster" + assert_not_includes names, "biometrics-scan" + assert_not_includes names, "chaostreff-flyer" + end + + test "search returns an empty list for a blank term" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_blank_search_test") + + get :search, params: { :node_id => node.id, :q => "" } + + assert_equal [], JSON.parse(response.body) + end + + test "create attaches an asset to the node's editable page" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_create_test") + asset = Asset.create!(:name => "erfa-photo", :upload_content_type => "image/png") + + post :create, params: { :node_id => node.id, :asset_id => asset.id } + + assert_response :success + assert_includes node.draft.reload.related_assets.map(&:asset_id), asset.id + end + + test "create does not duplicate an already-attached asset" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_dup_test") + asset = Asset.create!(:name => "erfa-photo-2", :upload_content_type => "image/png") + node.draft.assets << asset + + post :create, params: { :node_id => node.id, :asset_id => asset.id } + + assert_response :success + assert_equal 1, node.draft.reload.related_assets.count + end + + test "destroy removes the attached asset" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_destroy_test") + asset = Asset.create!(:name => "old-photo", :upload_content_type => "image/png") + node.draft.assets << asset + related = node.draft.related_assets.first + + delete :destroy, params: { :node_id => node.id, :id => related.id } + + assert_response :success + assert_equal 0, node.draft.reload.related_assets.count + end + + test "update reorders the attached assets" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_reorder_test") + first = Asset.create!(:name => "first-photo", :upload_content_type => "image/png") + second = Asset.create!(:name => "second-photo", :upload_content_type => "image/png") + node.draft.assets << first + node.draft.assets << second + + second_related = node.draft.related_assets.find_by(:asset_id => second.id) + patch :update, params: { :node_id => node.id, :id => second_related.id, :position => 1 } + + assert_response :success + ordered_asset_ids = node.draft.reload.related_assets.map(&:asset_id) + assert_equal [second.id, first.id], ordered_asset_ids + end +end -- cgit v1.3 From 0b6ae26f3b4c3c8278e569fa6644a544e996c25d Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 16:36:38 +0200 Subject: Add per-node translation management: list, edit, destroy, compare Replaces the old locale-switch-and-edit-the-same-screen workflow, which conflated presentation locale with content locale and let an editor silently drift into editing the wrong language with no persistent signal that anything had changed. Non-default-locale content now has its own explicit routes and screens, never sharing a route param with the ambient chrome locale. - PageTranslationsController: index/show/edit/update/destroy, scoped to Page.non_default_locales; update handles first-time creation too, so there's no separate new/create step. - Reads go through the actual PageTranslation row (Page#translation_summary), never through the Globalize fallback-bearing accessor -- fallback is correct for public rendering but wrong for editing, where a missing translation needs to look empty, not borrowed from another locale. - Translations ride on the page's own draft/head cycle; no independent publish state. - nodes#show gains a Translations section (per-locale Lock+Edit / Create+Lock, Destroy, a link into the read-only Compare view) and a locale indicator on its own default-locale content; nodes#edit, nodes#update, and nodes#autosave are pinned to the default locale via Globalize.with_locale regardless of the ambient route locale. - nodes#show no longer double-loads the node or calls wipe_draft! on every view (see previous commit for why that's now safe). - .preview_link_row is renamed .aligned_action_row now that it has a second real consumer. --- app/controllers/nodes_controller.rb | 12 ++- app/controllers/page_translations_controller.rb | 98 ++++++++++++++++++++++ app/models/page.rb | 21 +++++ app/views/nodes/show.html.erb | 49 +++++++++-- app/views/page_translations/edit.html.erb | 39 +++++++++ app/views/page_translations/index.html.erb | 19 +++++ app/views/page_translations/show.html.erb | 27 ++++++ config/routes.rb | 6 ++ public/stylesheets/admin.css | 27 +++++- .../page_translations_controller_test.rb | 78 +++++++++++++++++ 10 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 app/controllers/page_translations_controller.rb create mode 100644 app/views/page_translations/edit.html.erb create mode 100644 app/views/page_translations/index.html.erb create mode 100644 app/views/page_translations/show.html.erb create mode 100644 test/controllers/page_translations_controller_test.rb (limited to 'config/routes.rb') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index c1468be..249602d 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -16,6 +16,8 @@ class NodesController < ApplicationController :revert ] + around_action :pin_to_default_locale, :only => [:show, :edit, :update, :autosave] + def index @nodes = Node.root.descendants.includes(:head, :draft) .order('id DESC') @@ -59,9 +61,9 @@ class NodesController < ApplicationController end def show - node = Node.find(params[:id]) - node.wipe_draft! - @page = node.draft || node.head + @page = @node.draft || @node.head + @default_translation = @page.translations.find_by(:locale => I18n.default_locale) + @translations = @page.translation_summary end def edit @@ -248,6 +250,10 @@ class NodesController < ApplicationController end end + def pin_to_default_locale + Globalize.with_locale(I18n.default_locale) { yield } + end + def descendant_counts_for(ordered_with_level) counts = Hash.new(0) stack = [] # [node, level, index] diff --git a/app/controllers/page_translations_controller.rb b/app/controllers/page_translations_controller.rb new file mode 100644 index 0000000..47f01f7 --- /dev/null +++ b/app/controllers/page_translations_controller.rb @@ -0,0 +1,98 @@ +class PageTranslationsController < ApplicationController + layout 'admin' + + before_action :login_required + before_action :find_node + before_action :find_locale, :only => [:show, :edit, :update, :destroy] + + def index + page = @node.draft || @node.head + @translations = page ? page.translation_summary : [] + end + + def show + @page = @node.draft || @node.head + @translation = @page.translations.find_by(:locale => @locale) + @default_translation = @page.translations.find_by(:locale => I18n.default_locale) + end + + def edit + @node.lock_for_editing!(current_user) + @page = @node.draft || @node.head + @translation = @page.translations.find_by(:locale => @locale) + rescue LockedByAnotherUser => e + flash[:error] = e.message + redirect_to node_path(@node) + end + + def update + draft = ensure_editable_draft + Globalize.with_locale(@locale) { draft.update!(translation_params) } + flash[:notice] = "#{@locale.upcase} translation saved. Publish the draft to make it live." + + if params[:commit] == "Save + Unlock + Exit" + @node.unlock! + redirect_to node_path(@node) + else + redirect_to edit_node_translation_path(@node, @locale) + end + rescue LockedByAnotherUser => e + flash[:error] = e.message + redirect_to node_path(@node) + end + + def destroy + base = @node.draft || @node.head + unless base && base.translated_locales.include?(@locale) + flash[:error] = "No #{@locale.to_s.upcase} translation exists to remove." + return redirect_to node_path(@node) + end + + if (base.translated_locales - [@locale]).empty? + flash[:error] = "Can't remove the only remaining translation." + return redirect_to node_path(@node) + end + + draft = ensure_editable_draft + draft.translations.where(:locale => @locale).delete_all + draft.reload + + flash[:notice] = "#{@locale.upcase} translation removed from the draft. Publish to make this permanent." + redirect_to node_path(@node) + rescue LockedByAnotherUser => e + flash[:error] = e.message + redirect_to node_path(@node) + end + + private + + def find_node + @node = Node.find(params[:node_id]) + end + + # Every remaining action's :translation_locale is already + # router-constrained to non-default locales, so this should never + # actually fire in practice -- it's a deliberate second check, kept + # as a drift detector in case the route constraint and this list + # (both driven by Page.non_default_locales) ever disagree. + def find_locale + candidate = params[:translation_locale].to_s.to_sym + unless Page.non_default_locales.include?(candidate) + raise ActionController::RoutingError, "Unknown or default locale" + end + @locale = candidate + end + + # Never mutates head directly. Locks first (same guard as the + # primary editor), then reuses an existing draft or creates one -- + # deliberately not going through the autosave buffer: this is a + # plain, explicit Save, not a live-typing surface. + def ensure_editable_draft + @node.lock_for_editing!(current_user) + @node.draft || @node.create_new_draft(current_user) + end + + def translation_params + params.fetch(:page, {}).permit(:title, :abstract, :body) + end +end diff --git a/app/models/page.rb b/app/models/page.rb index db5b688..f796228 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -87,6 +87,27 @@ class Page < ApplicationRecord end end + def self.non_default_locales + I18n.available_locales - [:root, I18n.default_locale] + end + + # One row per non-default locale, read from the actual translation + # row -- never through the locale-dependent accessor, so a locale + # with no real translation yet reports as absent rather than quietly + # showing a fallback value borrowed from another locale. + def translation_summary + Page.non_default_locales.map do |locale| + translation = translations.find_by(:locale => locale) + { + :locale => locale, + :exists => translation.present?, + :title => translation&.title, + :updated_at => translation&.updated_at, + :outdated => translation.present? && outdated_translations?(:locale => locale) + } + end + end + def self.untranslated(options = {:locale => :de}) PageTranslation.all.group_by(&:page_id).select do |k,v| v.size == 1 && v.map{|x| x.locale}.include?(options[:locale]) diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 54bb38f..e6a922b 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -1,6 +1,6 @@ <% locked_by_other = @node.locked? && @node.lock_owner != current_user %>
-

<%= title_for_node(@node) %>

+

<%= title_for_node(@node) %> (<%= I18n.default_locale.to_s.upcase %>)

Status
@@ -70,6 +70,46 @@ <% end %>
+
Translations
+
+
+
+ <%= I18n.default_locale.to_s.upcase %> (default) + <%= @page.title %> — updated <%= @default_translation&.updated_at || @page.updated_at %> +
+ <% @translations.each do |t| %> +
+ <%= t[:locale].to_s.upcase %> +
+ <% if t[:exists] %> + <%= link_to t[:title], node_translation_path(@node, t[:locale]) %> — updated <%= t[:updated_at] %> + <% if t[:outdated] %> + may be outdated + <% end %> + <% else %> + Not yet translated. + <% end %> + <% if locked_by_other %> + <%= t[:exists] ? "Lock + Edit" : "Create translation + Lock" %> + <% else %> + <%= link_to edit_node_translation_path(@node, t[:locale]), class: "action_button" do %> + <%= icon(t[:exists] ? "edit" : "language", library: "tabler", "aria-hidden": true) %> + <%= t[:exists] ? "Lock + Edit" : "Create translation + Lock" %> + <% end %> + <% if t[:exists] %> + <%= button_to node_translation_path(@node, t[:locale]), method: :delete, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + Destroy Translation + <% end %> + <% end %> + <% end %> +
+
+ <% end %> +
+
+
People
@@ -122,7 +162,7 @@
Public Preview <% if @node.draft.preview_token.present? %> - <% end %> -
Abstract
+
Abstract (<%= I18n.default_locale.to_s.upcase %>)
<%= sanitize(@page.abstract) %>
-
Body
+
Body (<%= I18n.default_locale.to_s.upcase %>)
<%= sanitize(@page.body) %>
- <%# Assets not yet addressed - no confirmed model/association seen for this page's attached assets %>
diff --git a/app/views/page_translations/edit.html.erb b/app/views/page_translations/edit.html.erb new file mode 100644 index 0000000..7371a42 --- /dev/null +++ b/app/views/page_translations/edit.html.erb @@ -0,0 +1,39 @@ +
+ Editing the <%= @locale.to_s.upcase %> translation of <%= title_for_node(@node) %>. +
+ +

+ Metadata such as images, tags, template, and author belong to the page + as a whole, not to a single translation — change those from the + <%= link_to 'default-locale editor', edit_node_path(@node) %>. +

+ +
+ <%= button_to 'Unlock + Back', unlock_node_path(@node), method: :put, + form: { class: 'button_to state_changing' }, + disabled: @node.autosave.present? %> + <%= submit_tag "Save #{@locale.to_s.upcase} translation", form: "translation_edit_form" %> + <%= submit_tag "Save + Unlock + Exit", form: "translation_edit_form" %> + <%= link_to "Preview ↗", preview_page_path(@page, :locale => @locale), target: "_blank", rel: "noopener", class: "preview_link" %> +
+ +
+ <%= form_with url: node_translation_path(@node, @locale), method: :patch, local: true, id: "translation_edit_form" do |f| %> +
+
Title
+
+ <%= text_field_tag "page[title]", @translation&.title %> +
+ +
Abstract
+
+ <%= text_area_tag "page[abstract]", @translation&.abstract %> +
+ +
Body
+
+ <%= text_area_tag "page[body]", @translation&.body, :class => 'with_editor' %> +
+
+ <% end %> +
diff --git a/app/views/page_translations/index.html.erb b/app/views/page_translations/index.html.erb new file mode 100644 index 0000000..a8c4f5d --- /dev/null +++ b/app/views/page_translations/index.html.erb @@ -0,0 +1,19 @@ +

Translations — <%= title_for_node(@node) %>

+ +
+
+ <% @translations.each do |t| %> +
+ <%= t[:locale].to_s.upcase %> + <% if t[:exists] %> + <%= t[:title] %> — updated <%= t[:updated_at] %> + <% if t[:outdated] %>may be outdated<% end %> + <%= link_to 'Edit', edit_node_translation_path(@node, t[:locale]) %> + <% else %> + Not yet translated. + <%= link_to 'Add', edit_node_translation_path(@node, t[:locale]) %> + <% end %> +
+ <% end %> +
+
diff --git a/app/views/page_translations/show.html.erb b/app/views/page_translations/show.html.erb new file mode 100644 index 0000000..c5bd151 --- /dev/null +++ b/app/views/page_translations/show.html.erb @@ -0,0 +1,27 @@ +

Compare — <%= title_for_node(@node) %>

+ +
+
+

<%= @locale.to_s.upcase %>

+
Title
+
<%= @translation&.title %>
+ +
Abstract
+
<%= sanitize(@translation&.abstract.to_s) %>
+ +
Body
+
<%= sanitize(@translation&.body.to_s) %>
+
+ +
+

<%= I18n.default_locale.to_s.upcase %> (default)

+
Title
+
<%= @default_translation&.title %>
+ +
Abstract
+
<%= sanitize(@default_translation&.abstract.to_s) %>
+ +
Body
+
<%= sanitize(@default_translation&.body.to_s) %>
+
+
diff --git a/config/routes.rb b/config/routes.rb index 5d61bae..38a497d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,6 +55,12 @@ Cccms::Application.routes.draw do put :revert end + resources :translations, controller: 'page_translations', + param: :translation_locale, + constraints: { translation_locale: /en/ }, + only: [:index, :show, :edit, :update, :destroy] + + resources :related_assets, only: [:create, :destroy, :update] do collection do get :search diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index c3c0cb4..f1f4c05 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -637,6 +637,28 @@ table.revisions_table tr:hover { margin: 0; } +/* ============================================================ + Translation compare view (page_translations#show) + ============================================================ */ + +.translation_compare { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +@media(min-width:1016px) { + .translation_compare { + flex-direction: row; + gap: 2rem; + } + + .translation_compare_column { + flex: 1; + min-width: 0; + } +} + table.user_table td.user_login { padding-right: 30px; } @@ -834,14 +856,15 @@ form.button_to button[type="submit"] { font-weight: bold; } -.preview_link_row { +.aligned_action_row { display: flex; flex-wrap: wrap; align-items: start; gap: 0.5rem; } -.preview_link_row form.button_to { +.aligned_action_row .action_button, +.aligned_action_row form.button_to { margin-top: -5px; } diff --git a/test/controllers/page_translations_controller_test.rb b/test/controllers/page_translations_controller_test.rb new file mode 100644 index 0000000..d84ac45 --- /dev/null +++ b/test/controllers/page_translations_controller_test.rb @@ -0,0 +1,78 @@ +require 'test_helper' + +class PageTranslationsControllerTest < ActionController::TestCase + test "index lists the default locale's existing translation and flags a missing one" do + login_as :quentin + node = Node.root.children.create!(:slug => "translations_index_test") + node.publish_draft! + + get :index, params: { :node_id => node.id } + + assert_response :success + assert_equal [:en], assigns(:translations).map { |t| t[:locale] } + assert_equal false, assigns(:translations).first[:exists] + end + + test "update creates a first-time translation on a fresh draft, leaving head untouched" do + login_as :quentin + node = Node.root.children.create!(:slug => "translations_create_test") + Globalize.with_locale(:de) { node.draft.update!(:title => "Deutscher Titel") } + node.publish_draft! + + patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "English Title" } } + + node.reload + assert_not_nil node.draft + assert_includes node.draft.translated_locales, :en + assert_not_includes node.head.translated_locales, :en + end + + test "no route exists for the default locale under the translations resource" do + login_as :quentin + node = Node.root.children.create!(:slug => "translations_route_constraint_test") + + assert_raises(ActionController::UrlGenerationError) do + patch :update, params: { :node_id => node.id, :translation_locale => "de", :page => { :title => "x" } } + end + end + + test "update with Save + Unlock + Exit unlocks the node and redirects to nodes#show" do + login_as :quentin + node = Node.root.children.create!(:slug => "translations_exit_test") + + patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "x" }, :commit => "Save + Unlock + Exit" } + + assert_nil node.reload.lock_owner + assert_redirected_to node_path(node) + end + + test "update saves the translation onto the draft" do + login_as :quentin + node = Node.root.children.create!(:slug => "translations_update_test") + Globalize.with_locale(:en) { node.draft.update!(:title => "Original") } + + patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "Revised" } } + + assert_equal "Revised", Globalize.with_locale(:en) { node.draft.reload.title } + end + + test "destroy refuses to remove the only remaining translation" do + login_as :quentin + node = Node.root.children.create!(:slug => "translations_destroy_last_test") + Globalize.with_locale(:en) { node.draft.update!(:title => "Only translation") } + node.draft.translations.where(:locale => :de).delete_all + + delete :destroy, params: { :node_id => node.id, :translation_locale => "en" } + + assert_equal "Can't remove the only remaining translation.", flash[:error] + end + + test "destroy is a safe no-op, not a false success, when the translation doesn't exist" do + login_as :quentin + node = Node.root.children.create!(:slug => "translations_destroy_missing_test") + + delete :destroy, params: { :node_id => node.id, :translation_locale => "en" } + + assert_match(/No EN translation exists/, flash[:error]) + end +end -- cgit v1.3 From aa14587b052a0aa7884aaf27183981975168ab14 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 17:09:31 +0200 Subject: Wire real autosave into translation editing The shared TinyMCE setup initializes cccms.setup_autosave() on any page with a textarea.with_editor, unconditionally starting a 7-second interval that submits to the form's data-autosave-url -- which the translation edit form never set, so the interval PUT the current page URL itself and 404'd on a nonexistent route. Fixed by actually giving it something to talk to, rather than suppressing it: a real autosave endpoint, and update now goes through Node#autosave!/#save_draft! -- the same pipeline the primary editor uses, fixed earlier this session for exactly this kind of cross-locale carryover. This was the autosave-buffer parity already flagged as due after the proof of concept; the shared JS just forced the timing. --- app/controllers/page_translations_controller.rb | 17 ++++++++++++++--- app/views/page_translations/edit.html.erb | 3 ++- config/routes.rb | 7 +++++-- test/controllers/page_translations_controller_test.rb | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) (limited to 'config/routes.rb') diff --git a/app/controllers/page_translations_controller.rb b/app/controllers/page_translations_controller.rb index 47f01f7..9446cd2 100644 --- a/app/controllers/page_translations_controller.rb +++ b/app/controllers/page_translations_controller.rb @@ -3,7 +3,7 @@ class PageTranslationsController < ApplicationController before_action :login_required before_action :find_node - before_action :find_locale, :only => [:show, :edit, :update, :destroy] + before_action :find_locale, :only => [:show, :edit, :update, :autosave, :destroy] def index page = @node.draft || @node.head @@ -26,8 +26,8 @@ class PageTranslationsController < ApplicationController end def update - draft = ensure_editable_draft - Globalize.with_locale(@locale) { draft.update!(translation_params) } + Globalize.with_locale(@locale) { @node.autosave!(translation_params, current_user) } + @node.save_draft!(current_user) flash[:notice] = "#{@locale.upcase} translation saved. Publish the draft to make it live." if params[:commit] == "Save + Unlock + Exit" @@ -41,6 +41,17 @@ class PageTranslationsController < ApplicationController redirect_to node_path(@node) end + def autosave + Globalize.with_locale(@locale) { @node.autosave!(translation_params, current_user) } + head :ok + rescue LockedByAnotherUser => e + render plain: e.message, status: :locked + rescue ActiveRecord::RecordInvalid => e + render plain: e.message, status: :unprocessable_entity + rescue StandardError => e + render plain: "Autosave failed", status: :internal_server_error + end + def destroy base = @node.draft || @node.head unless base && base.translated_locales.include?(@locale) diff --git a/app/views/page_translations/edit.html.erb b/app/views/page_translations/edit.html.erb index 7371a42..89b594e 100644 --- a/app/views/page_translations/edit.html.erb +++ b/app/views/page_translations/edit.html.erb @@ -18,7 +18,8 @@
- <%= form_with url: node_translation_path(@node, @locale), method: :patch, local: true, id: "translation_edit_form" do |f| %> + <%= form_with url: node_translation_path(@node, @locale), method: :patch, local: true, id: "translation_edit_form", + data: { autosave_url: autosave_node_translation_path(@node, @locale), show_url: node_path(@node) } do |f| %>
Title
diff --git a/config/routes.rb b/config/routes.rb index 38a497d..826bb18 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -58,8 +58,11 @@ Cccms::Application.routes.draw do resources :translations, controller: 'page_translations', param: :translation_locale, constraints: { translation_locale: /en/ }, - only: [:index, :show, :edit, :update, :destroy] - + only: [:index, :show, :edit, :update, :destroy] do + member do + put :autosave + end + end resources :related_assets, only: [:create, :destroy, :update] do collection do diff --git a/test/controllers/page_translations_controller_test.rb b/test/controllers/page_translations_controller_test.rb index d84ac45..8c899fd 100644 --- a/test/controllers/page_translations_controller_test.rb +++ b/test/controllers/page_translations_controller_test.rb @@ -18,6 +18,7 @@ class PageTranslationsControllerTest < ActionController::TestCase node = Node.root.children.create!(:slug => "translations_create_test") Globalize.with_locale(:de) { node.draft.update!(:title => "Deutscher Titel") } node.publish_draft! + node.lock_for_editing!(users(:quentin)) patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "English Title" } } @@ -39,6 +40,7 @@ class PageTranslationsControllerTest < ActionController::TestCase test "update with Save + Unlock + Exit unlocks the node and redirects to nodes#show" do login_as :quentin node = Node.root.children.create!(:slug => "translations_exit_test") + node.lock_for_editing!(users(:quentin)) patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "x" }, :commit => "Save + Unlock + Exit" } @@ -50,6 +52,7 @@ class PageTranslationsControllerTest < ActionController::TestCase login_as :quentin node = Node.root.children.create!(:slug => "translations_update_test") Globalize.with_locale(:en) { node.draft.update!(:title => "Original") } + node.lock_for_editing!(users(:quentin)) patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "Revised" } } @@ -75,4 +78,20 @@ class PageTranslationsControllerTest < ActionController::TestCase assert_match(/No EN translation exists/, flash[:error]) end + + test "autosave writes the translation without creating a new revision or touching the draft" do + login_as :quentin + node = Node.root.children.create!(:slug => "translations_autosave_test") + node.publish_draft! + node.lock_for_editing!(users(:quentin)) + page_count_before = node.pages.count + + put :autosave, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "in progress" } } + + assert_response :success + node.reload + assert_not_nil node.autosave + assert_equal page_count_before, node.pages.count + assert_equal "in progress", Globalize.with_locale(:en) { node.autosave.title } + end end -- cgit v1.3