diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-13 04:54:33 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-13 04:54:33 +0200 |
| commit | 8baac265059b70da0148487458ee4077b15f155e (patch) | |
| tree | b97b07a76bec2fef602fa01c1739a89a412d812f | |
| parent | b138f40a6493f7c4341fba196c48440e795babb9 (diff) | |
Asset picker: attach/detach/reorder UI, read-only view, autosave fix
Replaces nodes#edit's old Images section -- a hidden panel dumping
every image asset in the system unfiltered (#image_browser) plus a
raw drag-and-drop box (#image_box) -- with a small search-and-click
picker built on the endpoint from the last two commits. Attaching
posts immediately and appends the new thumbnail via a cloned
<template> -- icons only render correctly through the Rails helper
server-side, so the template holds real, pre-rendered markup for JS
to clone rather than duplicating raw SVG in a JS string. Reordering is
jQuery UI sortable on the small attached list only, with a dedicated
drag handle rather than the whole thumbnail.
Two bugs caught while click-testing, fixed here rather than shipped
and patched after: the search panel never closed after attaching an
image, since the success handler re-triggered focus to keep it open
for attaching several in a row -- which meant it just re-populated
itself forever instead of signaling "done." Fixed to close explicitly;
a click-outside-closes handler was added alongside it, matching the
affordance the top-bar search already has.
A real, independent, pre-existing data bug surfaced during the same
testing: Node#autosave!'s first-time-creation branch never carried
related assets forward from whatever page was previously current --
attach an image, let autosave fire once, and it silently landed on a
fresh, assetless Page row. Long-dormant, not introduced by this work,
just finally exercised by something that made it visible. Fixed inside
the `unless self.autosave` guard specifically -- running this on every
call, not just creation, would overwrite anything attached directly to
an existing autosave in between, a worse bug than the one being fixed.
nodes#show gains a read-only Images section, rendered only when a page
actually has attached images, so an attachment can be confirmed
present without entering the edit/lock cycle -- useful on its own, and
specifically useful the next time an asset bug needs investigating.
Its thumbnail CSS is shared with the edit view's picker via a class
(.thumbnail_list) rather than duplicated under a second name.
| -rw-r--r-- | app/controllers/related_assets_controller.rb | 3 | ||||
| -rw-r--r-- | app/models/node.rb | 1 | ||||
| -rw-r--r-- | app/views/layouts/admin.html.erb | 1 | ||||
| -rw-r--r-- | app/views/nodes/edit.html.erb | 39 | ||||
| -rw-r--r-- | app/views/nodes/show.html.erb | 11 | ||||
| -rw-r--r-- | public/javascripts/admin_interface.js | 4 | ||||
| -rw-r--r-- | public/javascripts/related_assets.js | 80 | ||||
| -rw-r--r-- | public/stylesheets/admin.css | 75 | ||||
| -rw-r--r-- | test/controllers/related_assets_controller_test.rb | 2 | ||||
| -rw-r--r-- | test/models/node_test.rb | 29 |
10 files changed, 230 insertions, 15 deletions
diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb index ae37d2f..479ebec 100644 --- a/app/controllers/related_assets_controller.rb +++ b/app/controllers/related_assets_controller.rb | |||
| @@ -26,7 +26,8 @@ class RelatedAssetsController < ApplicationController | |||
| 26 | id: related.id, | 26 | id: related.id, |
| 27 | asset_id: asset.id, | 27 | asset_id: asset.id, |
| 28 | name: asset.name, | 28 | name: asset.name, |
| 29 | thumb_url: asset.upload.url(:thumb) | 29 | thumb_url: asset.upload.url(:thumb), |
| 30 | url: node_related_asset_path(@node, related) | ||
| 30 | } | 31 | } |
| 31 | end | 32 | end |
| 32 | 33 | ||
diff --git a/app/models/node.rb b/app/models/node.rb index 0361c1e..6f435ee 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -80,6 +80,7 @@ class Node < ApplicationRecord | |||
| 80 | 80 | ||
| 81 | unless self.autosave | 81 | unless self.autosave |
| 82 | self.autosave = Page.create!(:editor => current_user) | 82 | self.autosave = Page.create!(:editor => current_user) |
| 83 | self.autosave.assets = (self.draft || self.head).assets if self.draft || self.head | ||
| 83 | self.save! | 84 | self.save! |
| 84 | end | 85 | end |
| 85 | self.autosave.assign_attributes(attributes) | 86 | self.autosave.assign_attributes(attributes) |
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 5ae363f..8bbc0d5 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb | |||
| @@ -13,6 +13,7 @@ | |||
| 13 | <link rel="stylesheet" href="/stylesheets/admin.css"> | 13 | <link rel="stylesheet" href="/stylesheets/admin.css"> |
| 14 | <script src="/javascripts/admin_search.js"></script> | 14 | <script src="/javascripts/admin_search.js"></script> |
| 15 | <script src="/javascripts/admin_interface.js"></script> | 15 | <script src="/javascripts/admin_interface.js"></script> |
| 16 | <script src="/javascripts/related_assets.js"></script> | ||
| 16 | <script> | 17 | <script> |
| 17 | var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; | 18 | var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; |
| 18 | var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; | 19 | var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; |
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 1ac05e0..e0e51d8 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb | |||
| @@ -76,24 +76,35 @@ | |||
| 76 | 76 | ||
| 77 | <div class="node_description">Images</div> | 77 | <div class="node_description">Images</div> |
| 78 | <div class="node_content"> | 78 | <div class="node_content"> |
| 79 | <ul id="image_box" rel="<%= @page.id %>"> | 79 | <div id="related_assets" |
| 80 | <% @page.assets.images.each do |image| %> | 80 | data-search-url="<%= search_node_related_assets_path(@node) %>" |
| 81 | <li rel="images_<%= image.id %>"> | 81 | data-create-url="<%= node_related_assets_path(@node) %>"> |
| 82 | <%= image_tag(image.upload.url(:thumb)) %> | 82 | <ul id="related_asset_list" class="thumbnail_list"> |
| 83 | </li> | 83 | <% @page.related_assets.includes(:asset).each do |related| %> |
| 84 | <% end %> | 84 | <li data-url="<%= node_related_asset_path(@node, related) %>"> |
| 85 | </ul> | 85 | <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> |
| 86 | <div class="clear_left right"> | 86 | <%= image_tag related.asset.upload.url(:thumb) %> |
| 87 | <a id="image_browser_toggle" class="unselected" href="#">image browser</a> | 87 | <button type="button" class="related_asset_remove" aria-label="Remove image"> |
| 88 | </div> | 88 | <%= icon("x", library: "tabler", "aria-hidden": true) %> |
| 89 | <div id="image_browser"> | 89 | </button> |
| 90 | <ul> | 90 | </li> |
| 91 | <% Asset.images.each do |image| %> | ||
| 92 | <li rel="images_<%= image.id %>"><%= image_tag(image.upload.url(:thumb)) %></li> | ||
| 93 | <% end %> | 91 | <% end %> |
| 94 | </ul> | 92 | </ul> |
| 93 | <%= text_field_tag nil, nil, id: "related_asset_search_term", placeholder: "Search images to attach…", autocomplete: "off" %> | ||
| 94 | <div id="related_asset_search_results" class="search_results"></div> | ||
| 95 | </div> | 95 | </div> |
| 96 | </div> | 96 | </div> |
| 97 | |||
| 98 | <template id="related_asset_template"> | ||
| 99 | <li> | ||
| 100 | <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> | ||
| 101 | <img src=""> | ||
| 102 | <button type="button" class="related_asset_remove" aria-label="Remove image"> | ||
| 103 | <%= icon("x", library: "tabler", "aria-hidden": true) %> | ||
| 104 | </button> | ||
| 105 | </li> | ||
| 106 | </template> | ||
| 107 | |||
| 97 | </div> | 108 | </div> |
| 98 | </details> | 109 | </details> |
| 99 | 110 | ||
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 280e492..8ea03cd 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb | |||
| @@ -152,6 +152,17 @@ | |||
| 152 | <div class="node_description">Tags</div> | 152 | <div class="node_description">Tags</div> |
| 153 | <div class="node_content node_info_group"><%= @page.tag_list %></div> | 153 | <div class="node_content node_info_group"><%= @page.tag_list %></div> |
| 154 | 154 | ||
| 155 | <% if @page.assets.images.any? %> | ||
| 156 | <div class="node_description">Images</div> | ||
| 157 | <div class="node_content node_info_group"> | ||
| 158 | <ul class="thumbnail_list"> | ||
| 159 | <% @page.assets.images.each do |asset| %> | ||
| 160 | <li><%= link_to image_tag(asset.upload.url(:thumb)), asset_path(asset) %></li> | ||
| 161 | <% end %> | ||
| 162 | </ul> | ||
| 163 | </div> | ||
| 164 | <% end %> | ||
| 165 | |||
| 155 | <div class="node_description">Events</div> | 166 | <div class="node_description">Events</div> |
| 156 | <div class="node_content node_info_group"> | 167 | <div class="node_content node_info_group"> |
| 157 | <ul> | 168 | <ul> |
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 3f6a0a9..619933d 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js | |||
| @@ -59,6 +59,10 @@ $(document).ready(function () { | |||
| 59 | event_search.initialize_search(); | 59 | event_search.initialize_search(); |
| 60 | } | 60 | } |
| 61 | 61 | ||
| 62 | if ($("#related_asset_search_term").length != 0) { | ||
| 63 | related_assets.initialize(); | ||
| 64 | } | ||
| 65 | |||
| 62 | if ($("#rrule_builder").length != 0) { | 66 | if ($("#rrule_builder").length != 0) { |
| 63 | rrule_builder.initialize(); | 67 | rrule_builder.initialize(); |
| 64 | } | 68 | } |
diff --git a/public/javascripts/related_assets.js b/public/javascripts/related_assets.js new file mode 100644 index 0000000..2955cbf --- /dev/null +++ b/public/javascripts/related_assets.js | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | related_assets = { | ||
| 2 | initialize: function() { | ||
| 3 | var container = $("#related_assets"); | ||
| 4 | var searchUrl = container.data("search-url"); | ||
| 5 | var createUrl = container.data("create-url"); | ||
| 6 | var list = $("#related_asset_list"); | ||
| 7 | |||
| 8 | initSearchPicker({ | ||
| 9 | inputSelector: "#related_asset_search_term", | ||
| 10 | resultsSelector: "#related_asset_search_results", | ||
| 11 | url: searchUrl, | ||
| 12 | loadOnFocus: true, | ||
| 13 | renderResults: function(data, results) { | ||
| 14 | var found = false; | ||
| 15 | data.forEach(function(asset) { | ||
| 16 | var item = $( | ||
| 17 | "<a href='#' class='related_asset_result'>" + | ||
| 18 | "<img src='" + asset.thumb_url + "' alt=''>" + | ||
| 19 | "<span>" + asset.name + "</span>" + | ||
| 20 | "</a>" | ||
| 21 | ); | ||
| 22 | item.bind("click", function() { | ||
| 23 | related_assets.attach(asset.id, createUrl, list); | ||
| 24 | return false; | ||
| 25 | }); | ||
| 26 | results.append(item); | ||
| 27 | found = true; | ||
| 28 | }); | ||
| 29 | return found; | ||
| 30 | } | ||
| 31 | }); | ||
| 32 | |||
| 33 | $(document).on("click", function(e) { | ||
| 34 | if (!$(e.target).closest("#related_assets").length) { | ||
| 35 | $("#related_asset_search_results").slideUp().empty(); | ||
| 36 | } | ||
| 37 | }); | ||
| 38 | |||
| 39 | list.sortable({ | ||
| 40 | handle: ".related_asset_handle", | ||
| 41 | update: function(event, ui) { | ||
| 42 | var newPosition = ui.item.index() + 1; // acts_as_list is 1-based; jQuery UI's index() is 0-based | ||
| 43 | $.ajax({ | ||
| 44 | type: "PATCH", | ||
| 45 | url: ui.item.data("url"), | ||
| 46 | data: "position=" + newPosition | ||
| 47 | }); | ||
| 48 | } | ||
| 49 | }); | ||
| 50 | |||
| 51 | list.on("click", ".related_asset_remove", function() { | ||
| 52 | var item = $(this).closest("li"); | ||
| 53 | $.ajax({ | ||
| 54 | type: "DELETE", | ||
| 55 | url: item.data("url"), | ||
| 56 | success: function() { | ||
| 57 | item.remove(); | ||
| 58 | } | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | }, | ||
| 62 | |||
| 63 | attach: function(assetId, createUrl, list) { | ||
| 64 | $.ajax({ | ||
| 65 | type: "POST", | ||
| 66 | url: createUrl, | ||
| 67 | data: "asset_id=" + assetId, | ||
| 68 | dataType: "json", | ||
| 69 | success: function(related) { | ||
| 70 | var item = $($("#related_asset_template").html().trim()); | ||
| 71 | item.attr("data-url", related.url); | ||
| 72 | item.find("img").attr("src", related.thumb_url); | ||
| 73 | list.append(item); | ||
| 74 | list.sortable("refresh"); | ||
| 75 | $("#related_asset_search_term").val(""); | ||
| 76 | $("#related_asset_search_results").slideUp().empty(); | ||
| 77 | } | ||
| 78 | }); | ||
| 79 | } | ||
| 80 | }; | ||
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index a58a061..c3c0cb4 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css | |||
| @@ -1210,3 +1210,78 @@ div#image_browser { | |||
| 1210 | div#image_browser ul li { | 1210 | div#image_browser ul li { |
| 1211 | list-style-type: none; | 1211 | list-style-type: none; |
| 1212 | } | 1212 | } |
| 1213 | |||
| 1214 | .thumbnail_list { | ||
| 1215 | display: flex; | ||
| 1216 | flex-wrap: wrap; | ||
| 1217 | gap: 0.75rem; | ||
| 1218 | list-style: none; | ||
| 1219 | margin: 0 0 0.75rem; | ||
| 1220 | padding: 0; | ||
| 1221 | } | ||
| 1222 | |||
| 1223 | .thumbnail_list li { | ||
| 1224 | position: relative; | ||
| 1225 | display: flex; | ||
| 1226 | align-items: center; | ||
| 1227 | gap: 0.25rem; | ||
| 1228 | border: 1px solid #e8e8e8; | ||
| 1229 | border-radius: 6px; | ||
| 1230 | padding: 4px; | ||
| 1231 | } | ||
| 1232 | |||
| 1233 | .thumbnail_list img { | ||
| 1234 | display: block; | ||
| 1235 | width: 60px; | ||
| 1236 | height: 60px; | ||
| 1237 | object-fit: cover; | ||
| 1238 | border-radius: 4px; | ||
| 1239 | } | ||
| 1240 | |||
| 1241 | .related_asset_handle { | ||
| 1242 | display: flex; | ||
| 1243 | color: #969696; | ||
| 1244 | cursor: grab; | ||
| 1245 | } | ||
| 1246 | |||
| 1247 | .related_asset_handle svg { | ||
| 1248 | width: 1rem; | ||
| 1249 | height: 1rem; | ||
| 1250 | } | ||
| 1251 | |||
| 1252 | .related_asset_remove { | ||
| 1253 | display: flex; | ||
| 1254 | align-items: center; | ||
| 1255 | justify-content: center; | ||
| 1256 | background: none; | ||
| 1257 | border: none; | ||
| 1258 | padding: 2px; | ||
| 1259 | cursor: pointer; | ||
| 1260 | color: #969696; | ||
| 1261 | } | ||
| 1262 | |||
| 1263 | .related_asset_remove svg { | ||
| 1264 | width: 0.9rem; | ||
| 1265 | height: 0.9rem; | ||
| 1266 | } | ||
| 1267 | |||
| 1268 | .related_asset_remove:hover { | ||
| 1269 | color: #cc0000; | ||
| 1270 | } | ||
| 1271 | |||
| 1272 | .related_asset_result { | ||
| 1273 | display: flex; | ||
| 1274 | align-items: center; | ||
| 1275 | gap: 0.5rem; | ||
| 1276 | padding: 4px; | ||
| 1277 | border-bottom: 1px solid #e8e8e8; | ||
| 1278 | text-decoration: none; | ||
| 1279 | } | ||
| 1280 | |||
| 1281 | .related_asset_result img { | ||
| 1282 | width: 40px; | ||
| 1283 | height: 40px; | ||
| 1284 | object-fit: cover; | ||
| 1285 | border-radius: 4px; | ||
| 1286 | } | ||
| 1287 | |||
diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb index 7f6d487..9d20cbb 100644 --- a/test/controllers/related_assets_controller_test.rb +++ b/test/controllers/related_assets_controller_test.rb | |||
| @@ -44,6 +44,8 @@ class RelatedAssetsControllerTest < ActionController::TestCase | |||
| 44 | 44 | ||
| 45 | assert_response :success | 45 | assert_response :success |
| 46 | assert_includes node.draft.reload.related_assets.map(&:asset_id), asset.id | 46 | assert_includes node.draft.reload.related_assets.map(&:asset_id), asset.id |
| 47 | json = JSON.parse(response.body) | ||
| 48 | assert json["url"].present? | ||
| 47 | end | 49 | end |
| 48 | 50 | ||
| 49 | test "create does not duplicate an already-attached asset" do | 51 | test "create does not duplicate an already-attached asset" do |
diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 0bce222..a1da82c 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb | |||
| @@ -585,4 +585,33 @@ class NodeTest < ActiveSupport::TestCase | |||
| 585 | 585 | ||
| 586 | assert_not_includes Node.recently_changed, node | 586 | assert_not_includes Node.recently_changed, node |
| 587 | end | 587 | end |
| 588 | |||
| 589 | test "autosave! carries over the current related assets to the newly created autosave row" do | ||
| 590 | node = Node.root.children.create!(:slug => "autosave_asset_carryover_test") | ||
| 591 | user = User.find_by_login("quentin") | ||
| 592 | asset = Asset.create!(:name => "carryover-photo", :upload_content_type => "image/png") | ||
| 593 | node.draft.assets << asset | ||
| 594 | |||
| 595 | node.lock_for_editing!(user) | ||
| 596 | node.autosave!({:title => "v1"}, user) | ||
| 597 | |||
| 598 | assert_includes node.autosave.reload.assets, asset | ||
| 599 | end | ||
| 600 | |||
| 601 | test "autosave! does not reset assets already attached directly to an existing autosave" do | ||
| 602 | node = Node.root.children.create!(:slug => "autosave_asset_no_reset_test") | ||
| 603 | user = User.find_by_login("quentin") | ||
| 604 | original = Asset.create!(:name => "original-photo", :upload_content_type => "image/png") | ||
| 605 | extra = Asset.create!(:name => "extra-photo", :upload_content_type => "image/png") | ||
| 606 | node.draft.assets << original | ||
| 607 | |||
| 608 | node.lock_for_editing!(user) | ||
| 609 | node.autosave!({:title => "v1"}, user) | ||
| 610 | node.autosave.assets << extra | ||
| 611 | |||
| 612 | node.autosave!({:title => "v2"}, user) | ||
| 613 | |||
| 614 | assert_includes node.autosave.reload.assets, original | ||
| 615 | assert_includes node.autosave.reload.assets, extra | ||
| 616 | end | ||
| 588 | end | 617 | end |
