summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/assets_controller.rb22
-rw-r--r--app/controllers/nodes_controller.rb16
-rw-r--r--app/models/asset.rb9
-rw-r--r--app/models/node.rb64
-rw-r--r--app/views/assets/edit.html.erb17
-rw-r--r--app/views/assets/new.html.erb17
-rw-r--r--app/views/assets/show.html.erb25
-rw-r--r--app/views/layouts/_flash.html.erb24
-rw-r--r--app/views/layouts/admin.html.erb17
-rw-r--r--app/views/nodes/_recent_change_item.html.erb9
-rw-r--r--app/views/nodes/edit.html.erb129
-rw-r--r--app/views/nodes/new.html.erb18
-rw-r--r--app/views/nodes/recent.html.erb7
-rw-r--r--app/views/nodes/show.html.erb29
-rw-r--r--config/routes.rb1
-rw-r--r--doc/DESIGN_HISTORY.md36
-rw-r--r--public/javascripts/admin_interface.js12
-rw-r--r--public/javascripts/admin_search.js17
-rw-r--r--public/stylesheets/admin.css80
-rw-r--r--test/controllers/assets_controller_test.rb41
-rw-r--r--test/controllers/nodes_controller_test.rb39
-rw-r--r--test/models/node_attach_asset_test.rb107
-rw-r--r--test/models/node_test.rb24
23 files changed, 573 insertions, 187 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb
index becfe13..fbede0a 100644
--- a/app/controllers/assets_controller.rb
+++ b/app/controllers/assets_controller.rb
@@ -28,6 +28,7 @@ class AssetsController < ApplicationController
28 # GET /assets/new.xml 28 # GET /assets/new.xml
29 def new 29 def new
30 @asset = Asset.new 30 @asset = Asset.new
31 @attach_node = Node.not_in_trash.find_by(:id => params[:node_id]) if params[:node_id].present?
31 32
32 respond_to do |format| 33 respond_to do |format|
33 format.html # new.html.erb 34 format.html # new.html.erb
@@ -44,10 +45,12 @@ class AssetsController < ApplicationController
44 # POST /assets.xml 45 # POST /assets.xml
45 def create 46 def create
46 @asset = Asset.new(asset_params) 47 @asset = Asset.new(asset_params)
48 attach_node = Node.not_in_trash.find_by(:id => params[:node_id]) if params[:node_id].present?
47 49
48 respond_to do |format| 50 respond_to do |format|
49 if @asset.save 51 if @asset.save
50 flash[:notice] = 'Asset was successfully created.' 52 flash[:notice] = 'Asset was successfully created.'
53 attach_to(attach_node) if attach_node
51 format.html { redirect_to(@asset) } 54 format.html { redirect_to(@asset) }
52 format.xml { render :xml => @asset, :status => :created, :location => @asset } 55 format.xml { render :xml => @asset, :status => :created, :location => @asset }
53 else 56 else
@@ -91,4 +94,23 @@ class AssetsController < ApplicationController
91 def asset_params 94 def asset_params
92 params.require(:asset).permit(:name, :upload, :creator, :source_url, :license_key) 95 params.require(:asset).permit(:name, :upload, :creator, :source_url, :license_key)
93 end 96 end
97
98 def attach_to node
99 result = node.attach_asset!(@asset, :user => current_user,
100 :headline => params[:headline].present?)
101 flash[:notice] =
102 if result[:attached].zero?
103 "Asset saved — it was already attached to “#{node.title}”."
104 else
105 "Asset was successfully created and attached to “#{node.title}”."
106 end
107 case result[:headline]
108 when :set then flash[:notice] += " It is now the page's headline."
109 when :kept_existing then flash[:headline_kept_path] = node_path(node)
110 when :not_eligible then flash[:error] = "This asset type cannot be a headline."
111 end
112 rescue LockedByAnotherUser
113 flash[:locked_by] = node.lock_owner&.login
114 flash[:locked_node_path] = node_path(node)
115 end
94end 116end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 6caa827..ce8f053 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -30,6 +30,7 @@ class NodesController < ApplicationController
30 @node = Node.new node_params 30 @node = Node.new node_params
31 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 31 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
32 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id) 32 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
33 @attach_asset = Asset.find(params[:asset_id]) if params.has_key?(:asset_id)
33 end 34 end
34 35
35 def create 36 def create
@@ -51,10 +52,19 @@ class NodesController < ApplicationController
51 :action => "create", 52 :action => "create",
52 :title => params[:title], :path => @node.unique_name) 53 :title => params[:title], :path => @node.unique_name)
53 54
55 if params[:asset_id].present? && (asset = Asset.find(params[:asset_id]))
56 result = @node.attach_asset!(asset, :user => current_user,
57 :headline => params[:asset_headline].present?)
58 flash[:notice] = "Page created with “#{asset.name}” attached."
59 flash[:notice] += " It is the page's headline." if result[:headline] == :set
60 flash[:error] = "This asset type cannot be a headline." if result[:headline] == :not_eligible
61 end
62
54 redirect_to(edit_node_path(@node)) 63 redirect_to(edit_node_path(@node))
55 else 64 else
56 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 65 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
57 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id) 66 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
67 @attach_asset = Asset.find(params[:asset_id]) if params.has_key?(:asset_id)
58 render :new 68 render :new
59 end 69 end
60 end 70 end
@@ -77,7 +87,7 @@ class NodesController < ApplicationController
77 "This page has unsaved changes from a previous session, shown below. " \ 87 "This page has unsaved changes from a previous session, shown below. " \
78 "Save to keep them, or use \"Discard Autosave\" below to go back to the last saved version." 88 "Save to keep them, or use \"Discard Autosave\" below to go back to the last saved version."
79 elsif freshly_locked 89 elsif freshly_locked
80 flash.now[:notice] = "Node locked and ready to edit" 90 flash.now[:notice] ||= "Node locked and ready to edit"
81 end 91 end
82 rescue LockedByAnotherUser => e 92 rescue LockedByAnotherUser => e
83 flash[:error] = e.message 93 flash[:error] = e.message
@@ -223,10 +233,6 @@ class NodesController < ApplicationController
223 @nodes = index_matching(Node.drafts_and_autosaves) 233 @nodes = index_matching(Node.drafts_and_autosaves)
224 end 234 end
225 235
226 def recent
227 @nodes = index_matching(Node.recently_changed)
228 end
229
230 def mine 236 def mine
231 base = Node.joins(:pages) 237 base = Node.joins(:pages)
232 .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) 238 .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user)
diff --git a/app/models/asset.rb b/app/models/asset.rb
index ba9c2f0..73970e2 100644
--- a/app/models/asset.rb
+++ b/app/models/asset.rb
@@ -31,4 +31,13 @@ class Asset < ApplicationRecord
31 def show_credit? 31 def show_credit?
32 image? && has_credit? 32 image? && has_credit?
33 end 33 end
34
35 # Nodes whose current lifecycle rows (head, draft or autosave) carry
36 # this asset. Historical revisions also hold RelatedAsset rows; they
37 # are excluded by construction here, since nothing points at them.
38 def attached_nodes
39 page_ids = related_assets.select(:page_id)
40 Node.where("head_id IN (:ids) OR draft_id IN (:ids) OR autosave_id IN (:ids)",
41 :ids => page_ids).distinct
42 end
34end 43end
diff --git a/app/models/node.rb b/app/models/node.rb
index 0796ea4..a5a40d3 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -442,6 +442,63 @@ class Node < ApplicationRecord
442 end 442 end
443 end 443 end
444 444
445 # Attaches an asset to every current lifecycle row -- head, draft and
446 # autosave -- that does not already carry it. Attachments are page-
447 # scoped content (RelatedAsset belongs_to :page; drafts and autosaves
448 # are wholesale clones), so attaching to a single layer is how an
449 # attachment gets silently lost when another layer replaces it at
450 # publish or save. This is the out-of-band counterpart to the
451 # in-editor attach UI; it refuses when someone else holds the editing
452 # lock. Attaching to a head row changes the public page immediately,
453 # by design -- same reasoning as formalizing an already-existing
454 # editorial link.
455 #
456 # headline is a node-level decision: the flag is set on the newly
457 # created joins only when no current row has a headline yet and the
458 # asset is eligible; otherwise the asset is attached plain and the
459 # result says why, so the caller can point the editor at the star in
460 # the editor instead.
461 #
462 # Returns { :attached => n, :already => n,
463 # :headline => nil | :set | :kept_existing | :not_eligible }
464 def attach_asset! asset, user:, headline: false
465 if in_trash? || trash_node?
466 raise ActiveRecord::RecordInvalid.new(self), "Cannot attach assets to a node in the Trash"
467 end
468
469 if lock_owner && lock_owner != user
470 raise(
471 LockedByAnotherUser,
472 "Page is locked by another user who is working on it! " \
473 "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
474 )
475 end
476
477 rows = [head, draft, autosave].compact
478 to_attach = rows.reject { |row| row.related_assets.exists?(:asset_id => asset.id) }
479
480 headline_state =
481 if headline && to_attach.any?
482 if !(asset.image? || asset.pdf?)
483 :not_eligible
484 elsif rows.any? { |row| row.headline_asset.present? }
485 :kept_existing
486 else
487 :set
488 end
489 end
490
491 ActiveRecord::Base.transaction do
492 to_attach.each do |row|
493 row.related_assets.create!(:asset => asset, :headline => headline_state == :set)
494 end
495 end
496
497 { :attached => to_attach.size,
498 :already => rows.size - to_attach.size,
499 :headline => headline_state }
500 end
501
445 def title 502 def title
446 editable_page&.title 503 editable_page&.title
447 end 504 end
@@ -533,13 +590,6 @@ class Node < ApplicationRecord
533 throw :abort 590 throw :abort
534 end 591 end
535 592
536 def self.recently_changed
537 includes(:head).where(
538 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
539 Time.now, Time.now - 14.days
540 ).order("pages.updated_at desc").references(:head)
541 end
542
543 protected 593 protected
544 def lock_for! current_user 594 def lock_for! current_user
545 self.lock_owner = current_user 595 self.lock_owner = current_user
diff --git a/app/views/assets/edit.html.erb b/app/views/assets/edit.html.erb
index 3ef8fea..0c1dd8f 100644
--- a/app/views/assets/edit.html.erb
+++ b/app/views/assets/edit.html.erb
@@ -31,6 +31,23 @@
31 ) %> 31 ) %>
32 </div> 32 </div>
33 33
34 <div class="node_description">attach to page</div>
35 <div class="node_content">
36 <div class="restore_picker">
37 <%= text_field_tag :asset_node_search_term, @attach_node&.title,
38 :placeholder => "Search for a page…", :autocomplete => "off" %>
39 <div id="asset_node_search_results" class="search_results" style="display: none"></div>
40 </div>
41 <%= hidden_field_tag :node_id, @attach_node&.id %>
42 <span class="field_hint">Optional — attaches this asset to that page, all pending versions included.</span>
43 </div>
44
45 <div class="node_description"></div>
46 <div class="node_content">
47 <label><%= check_box_tag :headline, "1" %> attach as the page's headline</label>
48 <span class="field_hint">Applies only if the page has no headline yet.</span>
49 </div>
50
34 <div class="node_description">Actions</div> 51 <div class="node_description">Actions</div>
35 <div class="node_content node_info_group"> 52 <div class="node_content node_info_group">
36 <div class="node_info_group_items"> 53 <div class="node_info_group_items">
diff --git a/app/views/assets/new.html.erb b/app/views/assets/new.html.erb
index 2cf8865..7bded94 100644
--- a/app/views/assets/new.html.erb
+++ b/app/views/assets/new.html.erb
@@ -26,6 +26,23 @@
26 ) %> 26 ) %>
27 </div> 27 </div>
28 28
29 <div class="node_description">attach to page</div>
30 <div class="node_content">
31 <div class="restore_picker">
32 <%= text_field_tag :asset_node_search_term, @attach_node&.title,
33 :placeholder => "Search for a page…", :autocomplete => "off" %>
34 <div id="asset_node_search_results" class="search_results" style="display: none"></div>
35 </div>
36 <%= hidden_field_tag :node_id, @attach_node&.id %>
37 <span class="field_hint">Optional — attaches this asset to that page, all pending versions included.</span>
38 </div>
39
40 <div class="node_description"></div>
41 <div class="node_content">
42 <label><%= check_box_tag :headline, "1" %> attach as the page's headline</label>
43 <span class="field_hint">Applies only if the page has no headline yet.</span>
44 </div>
45
29 <div class="node_description">Actions</div> 46 <div class="node_description">Actions</div>
30 <div class="node_content node_info_group"> 47 <div class="node_content node_info_group">
31 <div class="node_info_group_items"> 48 <div class="node_info_group_items">
diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb
index e551e35..2723821 100644
--- a/app/views/assets/show.html.erb
+++ b/app/views/assets/show.html.erb
@@ -21,6 +21,31 @@
21 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div> 21 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div>
22 <% end %> 22 <% end %>
23 23
24 <div class="node_description">Attached to</div>
25 <div class="node_content">
26 <% nodes = @asset.attached_nodes %>
27 <% if nodes.any? %>
28 <ul class="search_results_list">
29 <% nodes.each do |node| %>
30 <li>
31 <%= link_to node.title, node_path(node) %>
32 <% if node.editable_page&.headline_asset == @asset %>
33 <%= icon("star", library: "tabler", "aria-hidden": true) %>
34 <% end %>
35 <% unless node.head && node.head.related_assets.exists?(:asset_id => @asset.id) %>
36 <span class="field_hint">pending versions only</span>
37 <% end %>
38 </li>
39 <% end %>
40 </ul>
41 <% else %>
42 — not attached to any page —
43 <% end %>
44 <%= link_to new_node_path(:asset_id => @asset.id), :class => "action_button" do %>
45 <%= icon("file-plus", library: "tabler", "aria-hidden": true) %> New page with this attachment
46 <% end %>
47 </div>
48
24 <div class="node_description">Public Path</div> 49 <div class="node_description">Public Path</div>
25 <div class="node_content"> 50 <div class="node_content">
26 <% public_path = @asset.upload.url.sub(/\?\d+$/, "") %> 51 <% public_path = @asset.upload.url.sub(/\?\d+$/, "") %>
diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb
new file mode 100644
index 0000000..a67fc86
--- /dev/null
+++ b/app/views/layouts/_flash.html.erb
@@ -0,0 +1,24 @@
1<% if flash.any? %>
2<div id="flash">
3 <%= flash[:notice] %>
4 <% if flash[:status_path] %>
5 <%= link_to 'Go to Status', flash[:status_path] %>
6 <% end %>
7 <% if flash[:stale_locale_path] %>
8 The <%= flash[:stale_locale] %> translation may be out of date —
9 <%= link_to 'review it', flash[:stale_locale_path] %> too.
10 <% end %>
11 <% if flash[:locked_node_path] %>
12 <span class="warning">The page is locked by <%= flash[:locked_by] %> —
13 <%= link_to 'unlock it there', flash[:locked_node_path] %> first,
14 then attach from this asset's page.</span>
15 <% end %>
16 <% if flash[:headline_kept_path] %>
17 <span class="warning">The page's existing headline was kept —
18 <%= link_to 'change it there', flash[:headline_kept_path] %> if needed.</span>
19 <% end %>
20 <% if flash[:error] %>
21 <span id="flash_error"><%= flash[:error] %></span>
22 <% end %>
23</div>
24<% end %>
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index e220beb..89c9b55 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -37,25 +37,10 @@
37 </div> 37 </div>
38 </div> 38 </div>
39 <div class="admin_content_spacer"></div> 39 <div class="admin_content_spacer"></div>
40 <% if flash[:notice].present? || flash[:error].present? %> 40 <%= render "layouts/flash" %>
41 <div id="flash">
42 <%= flash[:notice] %>
43 <% if flash[:status_path] %>
44 <%= link_to 'Go to Status', flash[:status_path] %>
45 <% end %>
46 <% if flash[:stale_locale_path] %>
47 The <%= flash[:stale_locale] %> translation may be out of date —
48 <%= link_to 'review it', flash[:stale_locale_path] %> too.
49 <% end %>
50 <% if flash[:error] %>
51 <span id="flash_error"><%= flash[:error] %></span>
52 <% end %>
53 </div>
54 <% end %>
55 <div id="content"> 41 <div id="content">
56 <%= yield %> 42 <%= yield %>
57 </div> 43 </div>
58
59 <div id="results"></div> 44 <div id="results"></div>
60 </div> 45 </div>
61 </body> 46 </body>
diff --git a/app/views/nodes/_recent_change_item.html.erb b/app/views/nodes/_recent_change_item.html.erb
deleted file mode 100644
index 754a775..0000000
--- a/app/views/nodes/_recent_change_item.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
1<li>
2 <div>
3 <%= link_to title_for_node(node), node_path(node) %>
4 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
5 </div>
6 <span class="dashboard_widget_meta">
7 <%= (editor = node_head_editor(node)) ? t("last_edited_by", editor: editor) : t("last_edited") %>, <%= relative_time_phrase(node.head.updated_at) %>
8 </span>
9</li>
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb
index 12dc000..fc1d18f 100644
--- a/app/views/nodes/edit.html.erb
+++ b/app/views/nodes/edit.html.erb
@@ -33,48 +33,17 @@
33 </div> 33 </div>
34 <% end %> 34 <% end %>
35 35
36 <details id="metadata_details"> 36 <%= fields_for @page do |d| %>
37 <summary>Metadata (slug, parent, tags, template, author, images)</summary> 37 <div id="edit_grid">
38 <div id="metadata">
39 <div class="node_description">Slug</div>
40 <div class="node_content">
41 <%= f.text_field(
42 :staged_slug, :value => @node.staged_slug || @node.slug
43 )
44 %>
45 </div>
46 38
47 <div class="node_description">parent</div> 39 <div id="main_fields">
48 <div class="node_content"> 40 <div class="node_description">Title</div>
49 <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> 41 <div class="node_content"><%= d.text_field :title %></div>
50 <div id="move_to_search_results" class="search_results"></div>
51 <%= f.hidden_field(
52 :staged_parent_id,
53 :value => @node.staged_parent_id || @node.parent_id
54 )
55 %>
56 </div>
57 42
58 <%= fields_for @page do |d| %> 43 <div class="node_description">Abstract</div>
59 <div class="node_description">Tags - comma seperated</div> 44 <div class="node_content"><%= d.text_area :abstract %></div>
60 <div class="node_content"><%= text_field_tag :tag_list, @page.tag_list.join(', ') %></div>
61 45
62 <div class="node_description">Publish at</div> 46 <div class="node_description">Attachments</div>
63 <div class="node_content"><%= d.datetime_select :published_at, :value => @page.published_at %></div>
64
65 <div class="node_description">Template</div>
66 <div class="node_content">
67 <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
68 <span class="field_hint">Set automatically based on how this node was created - change it if needed.</span>
69 </div>
70
71 <div class="node_description">Author</div>
72 <div class="node_content">
73 <%= d.select :user_id, user_list,
74 :selected => @page.user_id || @node.draft&.user_id || @node.head&.user_id %>
75 </div>
76
77 <div class="node_description">Images</div>
78 <div class="node_content"> 47 <div class="node_content">
79 <div id="related_assets" 48 <div id="related_assets"
80 data-search-url="<%= search_node_related_assets_path(@node) %>" 49 data-search-url="<%= search_node_related_assets_path(@node) %>"
@@ -120,33 +89,73 @@
120 </button> 89 </button>
121 </li> 90 </li>
122 </template> 91 </template>
123
124 </div> 92 </div>
125 </details>
126 93
127 <div id="content"> 94 <details id="metadata_details">
128 <div class="node_description">Title</div> 95 <summary>Metadata (slug, parent, tags, template, author, images)</summary>
129 <div class="node_content"><%= d.text_field :title %></div> 96 <div id="metadata">
97 <div class="node_description">Slug</div>
98 <div class="node_content">
99 <%= f.text_field(
100 :staged_slug, :value => @node.staged_slug || @node.slug
101 )
102 %>
103 </div>
104
105 <div class="node_description">parent</div>
106 <div class="node_content">
107 <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %>
108 <p class="field_hint">Start typing to find a new parent for this node.</p>
109 <div id="move_to_search_results" class="search_results"></div>
110 <%= f.hidden_field(
111 :staged_parent_id,
112 :value => @node.staged_parent_id || @node.parent_id
113 )
114 %>
115 </div>
130 116
131 <div class="node_description">Abstract</div> 117 <div class="node_description">Tags</div>
132 <div class="node_content"><%= d.text_area :abstract %></div> 118 <div class="node_content">
119 <%= text_field_tag :tag_list, @page.tag_list.join(', ') %>
120 <span class="field_hint">Comma separated.</span>
121 </div>
133 122
134 <div class="node_description">Body</div> 123 <div class="node_description">Publish at</div>
135 <div class="body_toolbar_row"> 124 <div class="node_content"><%= d.datetime_select :published_at, :value => @page.published_at %></div>
136 <button type="button" id="preview_toggle" class="view_toggle" aria-pressed="false" aria-label="Toggle live preview" title="Toggle live preview">
137 <%= icon("layout-sidebar-right", library: "tabler", "aria-hidden": true) %> <span>Live preview</span>
138 </button>
139 <button type="button" id="preview_force_render" class="view_toggle" style="display:none" aria-label="Force preview render" title="Force preview render">
140 <%= icon("refresh", library: "tabler", "aria-hidden": true) %> <span>Force refresh</span>
141 </button>
142 </div>
143 125
144 <div id="editor_and_preview"> 126 <div class="node_description">Template</div>
145 <div class="preview_content"> 127 <div class="node_content">
146 <div class="node_content"><%= d.text_area :body, :class => 'with_editor' %></div> 128 <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
129 <span class="field_hint">Set automatically based on how this node was created - change it if needed.</span>
130 </div>
131
132 <div class="node_description">Author</div>
133 <div class="node_content">
134 <%= d.select :user_id, user_list,
135 :selected => @page.user_id || @node.draft&.user_id || @node.head&.user_id %>
136 </div>
137
138 </div>
139 </details>
140
141 <div id="content">
142 <div class="node_description">Body</div>
143 <div class="body_toolbar_row">
144 <button type="button" id="preview_toggle" class="view_toggle" aria-pressed="false" aria-label="Toggle live preview" title="Toggle live preview">
145 <%= icon("layout-sidebar-right", library: "tabler", "aria-hidden": true) %> <span>Live preview</span>
146 </button>
147 <button type="button" id="preview_force_render" class="view_toggle" style="display:none" aria-label="Force preview render" title="Force preview render">
148 <%= icon("refresh", library: "tabler", "aria-hidden": true) %> <span>Force refresh</span>
149 </button>
147 </div> 150 </div>
148 <div id="preview_panel" style="display:none"> 151
149 <iframe id="live_preview_iframe" title="Live preview" data-src="<%= preview_page_path(@page, :locale => I18n.default_locale) %>"></iframe> 152 <div id="editor_and_preview">
153 <div class="preview_content">
154 <div class="node_content"><%= d.text_area :body, :class => 'with_editor' %></div>
155 </div>
156 <div id="preview_panel" style="display:none">
157 <iframe id="live_preview_iframe" title="Live preview" data-src="<%= preview_page_path(@page, :locale => I18n.default_locale) %>"></iframe>
158 </div>
150 </div> 159 </div>
151 </div> 160 </div>
152 </div> 161 </div>
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb
index 805fbc9..1303e9e 100644
--- a/app/views/nodes/new.html.erb
+++ b/app/views/nodes/new.html.erb
@@ -48,6 +48,24 @@
48 <span class="field_hint">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).</span> 48 <span class="field_hint">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).</span>
49 </div> 49 </div>
50 50
51 <% if @attach_asset %>
52 <div class="node_description">attachment</div>
53 <div class="node_content">
54 <ul class="thumbnail_list">
55 <li>
56 <% if @attach_asset.has_variant?(:thumb) %>
57 <%= image_tag @attach_asset.upload.url(:thumb) %>
58 <% end %>
59 <%= link_to @attach_asset.name, asset_path(@attach_asset),
60 :target => "_blank", :rel => "noopener" %>
61 </li>
62 </ul>
63 <span class="field_hint">This asset will automatically be attached to the new page.</span>
64 <label><%= check_box_tag :asset_headline, "1" %> as the page's headline</label>
65 <%= hidden_field_tag :asset_id, @attach_asset.id %>
66 </div>
67 <% end %>
68
51 <div class="node_description"></div> 69 <div class="node_description"></div>
52 <div class="node_content"><%= submit_tag "Create" %></div> 70 <div class="node_content"><%= submit_tag "Create" %></div>
53 71
diff --git a/app/views/nodes/recent.html.erb b/app/views/nodes/recent.html.erb
deleted file mode 100644
index d256253..0000000
--- a/app/views/nodes/recent.html.erb
+++ /dev/null
@@ -1,7 +0,0 @@
1<h1>Recently changed</h1>
2
3<%= will_paginate @nodes %>
4<ul id="recent_changes_full_list">
5 <%= render partial: "nodes/recent_change_item", collection: @nodes, as: :node %>
6</ul>
7<%= will_paginate @nodes %>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index adf79dd..b3987c6 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -253,10 +253,10 @@
253 <% end %> 253 <% end %>
254 </div> 254 </div>
255 255
256 <% if @page.assets.any? %> 256 <div class="node_description">Attachments</div>
257 <% headline_asset_id = @page.headline_asset&.id %> 257 <div class="node_content node_info_group">
258 <div class="node_description">Images</div> 258 <% if @page.assets.any? %>
259 <div class="node_content node_info_group"> 259 <% headline_asset_id = @page.headline_asset&.id %>
260 <ul class="thumbnail_list"> 260 <ul class="thumbnail_list">
261 <% @page.assets.each do |asset| %> 261 <% @page.assets.each do |asset| %>
262 <li class="<%= "is_headline" if asset.id == headline_asset_id %>"> 262 <li class="<%= "is_headline" if asset.id == headline_asset_id %>">
@@ -269,8 +269,14 @@
269 </li> 269 </li>
270 <% end %> 270 <% end %>
271 </ul> 271 </ul>
272 </div> 272 <% end %>
273 <% end %> 273 <p class="add_attachments">
274 <%= link_to new_asset_path(:node_id => @node.id), class: 'action_button' do %>
275 <%= icon("paperclip", library: "tabler", "aria-hidden": true) %> Upload new attachment
276 <% end %>
277 <span class="field_hint">To add or remove existing attachments, edit this node instead.</span>
278 </p>
279 </div>
274 280
275 <div class="node_description">Events</div> 281 <div class="node_description">Events</div>
276 <div class="node_content node_info_group"> 282 <div class="node_content node_info_group">
@@ -284,7 +290,11 @@
284 <% end %> 290 <% end %>
285 </ul> 291 </ul>
286 <% mapping = default_event_tag_mapping(@page) %> 292 <% mapping = default_event_tag_mapping(@page) %>
287 <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %> 293 <p class="add_events">
294 <%= link_to new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path), class: 'action_button' do %>
295 <%= icon("calendar-plus", library: "tabler", "aria-hidden": true) %> Add event
296 <% end %>
297 </p>
288 </div> 298 </div>
289 299
290 <% matches = matching_node_kinds(@node) %> 300 <% matches = matching_node_kinds(@node) %>
@@ -304,10 +314,11 @@
304 <% if matches.any? %> 314 <% if matches.any? %>
305 <p class="add_child_links"> 315 <p class="add_child_links">
306 <% matches.each_with_index do |(kind, config), index| %> 316 <% matches.each_with_index do |(kind, config), index| %>
307 <%= " &middot; ".html_safe if index > 0 %>
308 <% link_params = { kind: kind } %> 317 <% link_params = { kind: kind } %>
309 <% link_params[:parent_id] = @node.id if kind == "generic" %> 318 <% link_params[:parent_id] = @node.id if kind == "generic" %>
310 <%= link_to "add '#{resolve_kind_text(config[:label])}' child", new_node_path(link_params) %> 319 <%= link_to new_node_path(link_params), class: 'action_button' do %>
320 <%= icon("text-plus", library: "tabler", "aria-hidden": true) %> Add child type <%= resolve_kind_text(config[:label]) %>
321 <% end %>
311 <% end %> 322 <% end %>
312 </p> 323 </p>
313 <% end %> 324 <% end %>
diff --git a/config/routes.rb b/config/routes.rb
index 20602f7..6223a40 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -41,7 +41,6 @@ Cccms::Application.routes.draw do
41 get 'tags/:tags', action: :tags, as: :tags, constraints: { tags: /[^\/]+/ } 41 get 'tags/:tags', action: :tags, as: :tags, constraints: { tags: /[^\/]+/ }
42 get :parameterize_preview 42 get :parameterize_preview
43 get :drafts 43 get :drafts
44 get :recent
45 get :mine 44 get :mine
46 get :chapters 45 get :chapters
47 get :sitemap 46 get :sitemap
diff --git a/doc/DESIGN_HISTORY.md b/doc/DESIGN_HISTORY.md
index 5eb664b..7f411a3 100644
--- a/doc/DESIGN_HISTORY.md
+++ b/doc/DESIGN_HISTORY.md
@@ -198,3 +198,39 @@ so the log can always answer, for any entry, how much to trust it.
198`app/models/node.rb`. That's the authoritative, current version; this 198`app/models/node.rb`. That's the authoritative, current version; this
199entry explains why it's shaped the way it is, not what it says field 199entry explains why it's shaped the way it is, not what it says field
200by field. 200by field.
201
202### Retired code
203
204The timestamp-driven surfaces this replaced — the dashboard widget
205and the nodes#recent page — are gone; admin/log is the sole
206recent-activity view.
207
208## Attachments: images and PDFs
209
210Originally attachments were images only; PDFs are now first-class,
211and the subsystems below treat both through one mechanism.
212
213Every attachment is an Asset joined to pages via RelatedAsset. The
214starred attachment ("headline") becomes the page's face on the
215public site; if none is starred, visitors get a link to browse all
216attached images instead.
217
218PDFs are headline-eligible because the standard variant set (thumb,
219medium, large, headline) is generated for them as PNG rasters of the
220document's first page (ImageMagick with Ghostscript); the original
221remains the PDF.
222
223Presentation differs by type at the last step only: an image
224headline opens in the lightbox, a PDF headline renders as a document
225card whose click delivers the document itself — a lightbox showing a
226raster of page one would misrepresent what the asset is. Pages list
227attached documents separately from the image gallery.
228
229Credit display (photographer, license, attribution) applies to
230images only; the vocabulary describes photographs and is deliberately
231not rendered for PDFs. Admin asset search matches filenames as well
232as names, since document filenames carry meaning in a way image
233filenames rarely do.
234
235The test suite runs against isolated storage and never touches the
236real upload tree; keep it that way when adding asset code.
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index f6d915f..5514f3b 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -65,6 +65,10 @@ $(document).ready(function () {
65 related_assets.initialize(); 65 related_assets.initialize();
66 } 66 }
67 67
68 if ($("#asset_node_search_term").length != 0) {
69 asset_node_search.initialize_search();
70 }
71
68 if ($("#rrule_builder").length != 0) { 72 if ($("#rrule_builder").length != 0) {
69 rrule_builder.initialize(); 73 rrule_builder.initialize();
70 } 74 }
@@ -73,6 +77,14 @@ $(document).ready(function () {
73 cccms.preview.initialize(); 77 cccms.preview.initialize();
74 } 78 }
75 79
80 var metadata_details = document.getElementById('metadata_details');
81 if (metadata_details) {
82 var desktop_mq = window.matchMedia('(min-width: 1016px)');
83 var sync_metadata = function() { metadata_details.open = desktop_mq.matches; };
84 sync_metadata();
85 desktop_mq.addEventListener('change', sync_metadata);
86 }
87
76 jQuery.ajaxSetup({ 88 jQuery.ajaxSetup({
77 'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript");} 89 'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript");}
78 }); 90 });
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js
index ad368cb..792849f 100644
--- a/public/javascripts/admin_search.js
+++ b/public/javascripts/admin_search.js
@@ -286,3 +286,20 @@ event_search = {
286 }); 286 });
287 } 287 }
288}; 288};
289
290asset_node_search = {
291 initialize_search : function() {
292 initSearchPicker({
293 inputSelector: "#asset_node_search_term",
294 resultsSelector: "#asset_node_search_results",
295 onSelect: function(node) {
296 $("#asset_node_search_term").val(node.title);
297 $("#node_id").val(node.node_id);
298 }
299 });
300
301 $("#asset_node_search_term").bind("input", function() {
302 if ($(this).val() === "") { $("#node_id").val(""); }
303 });
304 }
305};
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index ef6cb11..9f73775 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -97,7 +97,8 @@ input[type=radio] {
97} 97}
98 98
99#metadata, 99#metadata,
100#content { 100#content,
101#main_fields {
101 margin-left: 5px; 102 margin-left: 5px;
102} 103}
103 104
@@ -830,12 +831,11 @@ form.button_to button[type="submit"] {
830 831
831/* Layout only -- the at-rest visibility (wavy underline) for these 832/* Layout only -- the at-rest visibility (wavy underline) for these
832 links comes from the scoped rule in Base elements above. */ 833 links comes from the scoped rule in Base elements above. */
834.add_events,
835.add_attachments,
833.add_child_links { 836.add_child_links {
834 margin-top: 0.5rem; 837 margin-top: 0.5rem;
835} 838 margin-bottom: 0;
836
837.add_child_links a {
838 white-space: nowrap;
839} 839}
840 840
841.sitemap_node { 841.sitemap_node {
@@ -892,6 +892,26 @@ form.button_to button[type="submit"] {
892 Page editor / metadata forms 892 Page editor / metadata forms
893 ============================================================ */ 893 ============================================================ */
894 894
895@media(min-width:1016px) {
896 #edit_grid {
897 display: grid;
898 grid-template-columns: 1fr 1fr;
899 grid-template-areas:
900 "main meta"
901 "body body";
902 column-gap: 2rem;
903 align-items: start;
904 margin-left: -125px;
905 }
906 #main_fields { grid-area: main; }
907 #metadata_details { grid-area: meta; }
908 #page_editor #content { grid-area: body; }
909
910 /* Details is force-opened by JS on desktop; hide the summary only
911 once open, so a JS failure still leaves a working toggle. */
912 #metadata_details[open] > summary { display: none; }
913}
914
895#new_node { 915#new_node {
896 margin-left: -118px; 916 margin-left: -118px;
897} 917}
@@ -947,18 +967,16 @@ div#page_editor {
947} 967}
948 968
949@media(min-width:1016px) { 969@media(min-width:1016px) {
950 input#tag_list,
951 input#menu_search_term, 970 input#menu_search_term,
952 input#menu_item_node_id, 971 input#menu_item_node_id,
953 input#menu_item_path, 972 input#menu_item_path,
954 input#menu_item_title, 973 input#menu_item_title {
955 input#node_staged_slug,
956 input#move_to_search_term {
957 width: 690px; 974 width: 690px;
958 } 975 }
959 976
960 input[type=text]#page_title { 977 input[type=text]#page_title {
961 width: 690px; 978 width: 100%;
979 box-sizing: border-box;
962 font-size: 1rem; 980 font-size: 1rem;
963 padding-top: 6px; 981 padding-top: 6px;
964 padding-bottom: 4px; 982 padding-bottom: 4px;
@@ -968,24 +986,27 @@ div#page_editor {
968 } 986 }
969 987
970 textarea#page_abstract { 988 textarea#page_abstract {
971 width: 690px; 989 width: 100%;
972 height: 150px; 990 box-sizing: border-box;
991 height: 250px;
973 padding: 5px; 992 padding: 5px;
974 } 993 }
975 994
976 #page_editor textarea#page_body { 995 #page_editor textarea#page_body {
977 width: 700px; 996 width: 100%;
997 box-sizing: border-box;
978 height: 600px; 998 height: 600px;
979 } 999 }
980 1000
981 #page_editor #metadata, #page_editor #content, #admin_overview { 1001 #admin_overview {
982 margin-left: -125px; 1002 margin-left: -125px;
983 } 1003 }
984 1004
985 #page_editor input[type=text], 1005 #page_editor input[type=text],
986 #page_editor input[type=password], 1006 #page_editor input[type=password],
987 #page_editor textarea { 1007 #page_editor textarea {
988 width: 690px; 1008 width: 100%;
1009 box-sizing: border-box;
989 } 1010 }
990} 1011}
991 1012
@@ -998,18 +1019,14 @@ div#page_editor {
998 } 1019 }
999 1020
1000 #page_editor #content, 1021 #page_editor #content,
1001 #page_editor #metadata { 1022 #page_editor #metadata,
1023 #page_editor #main_fields {
1002 padding-right: 5px; 1024 padding-right: 5px;
1003 } 1025 }
1004 1026
1005 /* Fixed 800px width will overflow narrow viewports despite
1006 border-box -- every sibling rule in this block uses 100% instead.
1007 Worth checking on an actual phone; looks unfinished rather than
1008 intentional. Not changed here since it wasn't part of what you
1009 asked to fix. */
1010 #page_editor textarea#page_body { 1027 #page_editor textarea#page_body {
1011 box-sizing: border-box; 1028 box-sizing: border-box;
1012 width: 800px; 1029 width: 100%;
1013 height: 50rem; 1030 height: 50rem;
1014 } 1031 }
1015 1032
@@ -1124,25 +1141,6 @@ div#draft_list table td.actions a {
1124 font-size: 0.9em; 1141 font-size: 0.9em;
1125} 1142}
1126 1143
1127#recent_changes_full_list {
1128 list-style: none;
1129 padding: 0;
1130 margin: 0;
1131}
1132
1133#recent_changes_full_list li {
1134 padding: 0.75rem 0;
1135 border-bottom: 1px solid #eee;
1136}
1137
1138#recent_changes_full_list li:last-child {
1139 border-bottom: none;
1140}
1141
1142#recent_changes_full_list li > div {
1143 margin-bottom: 0.25rem;
1144}
1145
1146/* ============================================================ 1144/* ============================================================
1147 Search widgets 1145 Search widgets
1148 ============================================================ */ 1146 ============================================================ */
diff --git a/test/controllers/assets_controller_test.rb b/test/controllers/assets_controller_test.rb
index f834541..05fc6de 100644
--- a/test/controllers/assets_controller_test.rb
+++ b/test/controllers/assets_controller_test.rb
@@ -92,6 +92,47 @@ class AssetsControllerTest < ActionController::TestCase
92 end 92 end
93 end 93 end
94 94
95 # --- create with attach ---
96
97 test "create with node_id attaches the asset to the node's draft" do
98 node = Node.root.children.create!(:slug => "asset_attach_target")
99
100 post :create, params: { asset: { name: 'Attach me' }, node_id: node.id }
101
102 assert_response :redirect
103 asset = Asset.last
104 assert_includes node.draft.assets.reload, asset
105 assert_match /attached/, flash[:notice]
106 end
107
108 test "create against a foreign-locked node keeps the asset but refuses the attach" do
109 node = Node.root.children.create!(:slug => "asset_attach_locked")
110 node.lock_for_editing!(users(:aaron))
111
112 assert_difference 'Asset.count', 1 do
113 post :create, params: { asset: { name: 'Orphaned for now' }, node_id: node.id }
114 end
115
116 assert_empty node.draft.assets.reload
117 assert_equal node_path(node), flash[:locked_node_path]
118 assert_equal "aaron", flash[:locked_by]
119 end
120
121 test "create with headline against a page that has one keeps the incumbent and warns" do
122 node = Node.root.children.create!(:slug => "asset_attach_headline")
123 incumbent = Asset.create!(:name => 'Incumbent', :upload_content_type => 'image/png')
124 node.draft.related_assets.create!(:asset => incumbent, :headline => true)
125
126 uploaded = Rack::Test::UploadedFile.new(
127 Rails.root.join('test', 'fixtures', 'files', 'test_image.png'), 'image/png')
128 post :create, params: { asset: { name: 'Challenger', upload: uploaded },
129 node_id: node.id, headline: "1" }
130
131 assert_includes node.draft.assets.reload, Asset.last
132 assert_equal incumbent, node.draft.reload.headline_asset
133 assert_equal node_path(node), flash[:headline_kept_path]
134 end
135
95 # --- edit --- 136 # --- edit ---
96 137
97 test "get edit" do 138 test "get edit" do
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index ddc4565..d777108 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -79,6 +79,35 @@ class NodesControllerTest < ActionController::TestCase
79 end 79 end
80 end 80 end
81 81
82 test "create with asset_id attaches the asset to the new draft, as headline when asked" do
83 login_as :quentin
84 asset = Asset.create!(:name => 'Birth attachment', :upload_content_type => 'image/png')
85
86 post :create, params: { :kind => "generic", :parent_id => Node.root.id,
87 :title => "Born Attached",
88 :asset_id => asset.id, :asset_headline => "1" }
89
90 assert_response :redirect
91 node = Node.last
92 assert_includes node.draft.assets, asset
93 assert_equal asset, node.draft.headline_asset
94 assert_match /attached/, flash[:notice]
95 end
96
97 test "the attach notice survives the redirect into the editor" do
98 login_as :quentin
99 asset = Asset.create!(:name => 'Flash survivor', :upload_content_type => 'image/png')
100
101 post :create, params: { :kind => "generic", :parent_id => Node.root.id,
102 :title => "Notice Carrier", :asset_id => asset.id }
103 assert_redirected_to edit_node_path(Node.last)
104
105 get :edit, params: { :id => Node.last.id }
106 assert_response :success
107 assert_match /attached/, flash[:notice]
108 assert_no_match /ready to edit/, flash[:notice]
109 end
110
82 test "editing a node" do 111 test "editing a node" do
83 login_as :quentin 112 login_as :quentin
84 113
@@ -409,7 +438,7 @@ class NodesControllerTest < ActionController::TestCase
409 438
410 get :show, params: { id: node.id } 439 get :show, params: { id: node.id }
411 assert_response :success 440 assert_response :success
412 assert_select "a", text: "add event" 441 assert_select "a", text: "Add event"
413 assert_select "a[href*='tag_list=open-day']" 442 assert_select "a[href*='tag_list=open-day']"
414 assert_select "a[href*='auto_tag_source=erfa-detail']" 443 assert_select "a[href*='auto_tag_source=erfa-detail']"
415 end 444 end
@@ -420,7 +449,7 @@ class NodesControllerTest < ActionController::TestCase
420 449
421 get :show, params: { id: node.id } 450 get :show, params: { id: node.id }
422 assert_response :success 451 assert_response :success
423 assert_select "a", text: "add event" 452 assert_select "a", text: "Add event"
424 assert_select "a[href*='tag_list=']", count: 0 453 assert_select "a[href*='tag_list=']", count: 0
425 end 454 end
426 455
@@ -514,12 +543,6 @@ class NodesControllerTest < ActionController::TestCase
514 assert_includes assigns(:nodes), chaostreff_node 543 assert_includes assigns(:nodes), chaostreff_node
515 end 544 end
516 545
517 test "recent combined with a search term does not raise an ambiguous column error" do
518 login_as :quentin
519 get :recent, params: { :q => "Zombies" }
520 assert_response :success
521 end
522
523 test "drafts combined with a search term does not raise an ambiguous column error" do 546 test "drafts combined with a search term does not raise an ambiguous column error" do
524 login_as :quentin 547 login_as :quentin
525 get :drafts, params: { :q => "Zombies" } 548 get :drafts, params: { :q => "Zombies" }
diff --git a/test/models/node_attach_asset_test.rb b/test/models/node_attach_asset_test.rb
new file mode 100644
index 0000000..cb5b60f
--- /dev/null
+++ b/test/models/node_attach_asset_test.rb
@@ -0,0 +1,107 @@
1require "test_helper"
2
3class NodeAttachAssetTest < ActiveSupport::TestCase
4
5 def setup
6 @user = users(:quentin)
7 @other = users(:aaron)
8 @node = Node.root.children.create!(:slug => "attach_asset_test")
9 @image = create_image_asset
10 end
11
12 test "attaches to a draft-only node" do
13 result = @node.attach_asset!(@image, :user => @user)
14 assert_equal 1, result[:attached]
15 assert_includes @node.draft.assets, @image
16 end
17
18 test "attaches to head when no draft is pending" do
19 @node.publish_draft!(@user)
20 result = @node.attach_asset!(@image, :user => @user)
21 assert_equal 1, result[:attached]
22 assert_includes @node.head.assets, @image
23 end
24
25 test "attaches to head and pending draft alike" do
26 @node.publish_draft!(@user)
27 @node.lock_for_editing!(@user)
28 @node.create_new_draft(@user)
29 result = @node.attach_asset!(@image, :user => @user)
30 assert_equal 2, result[:attached]
31 assert_includes @node.head.assets, @image
32 assert_includes @node.draft.assets, @image
33 end
34
35 test "attaches to all three lifecycle rows" do
36 @node.publish_draft!(@user)
37 @node.lock_for_editing!(@user)
38 @node.create_new_draft(@user)
39 @node.autosave!({ :title => "wip" }, @user)
40 result = @node.attach_asset!(@image, :user => @user)
41 assert_equal 3, result[:attached]
42 [@node.head, @node.draft, @node.autosave].each do |row|
43 assert_includes row.assets, @image
44 end
45 end
46
47 test "skips rows that already carry the asset" do
48 @node.draft.related_assets.create!(:asset => @image)
49 @node.publish_draft!(@user)
50 @node.lock_for_editing!(@user)
51 @node.create_new_draft(@user)
52 result = @node.attach_asset!(@image, :user => @user)
53 assert_equal 0, result[:attached]
54 assert_equal 2, result[:already]
55 assert_equal 1, @node.draft.related_assets.where(:asset_id => @image.id).count
56 end
57
58 test "refuses when another user holds the lock and writes nothing" do
59 @node.lock_for_editing!(@other)
60 assert_raises(LockedByAnotherUser) { @node.attach_asset!(@image, :user => @user) }
61 assert_empty @node.draft.assets.reload
62 end
63
64 test "proceeds when the attaching user holds the lock" do
65 @node.lock_for_editing!(@user)
66 result = @node.attach_asset!(@image, :user => @user)
67 assert_equal 1, result[:attached]
68 end
69
70 test "sets the headline when none exists" do
71 result = @node.attach_asset!(@image, :user => @user, :headline => true)
72 assert_equal :set, result[:headline]
73 assert_equal @image, @node.draft.headline_asset
74 end
75
76 test "keeps an existing headline and reports it" do
77 incumbent = create_image_asset
78 @node.draft.related_assets.create!(:asset => incumbent, :headline => true)
79 result = @node.attach_asset!(@image, :user => @user, :headline => true)
80 assert_equal :kept_existing, result[:headline]
81 assert_equal incumbent, @node.draft.reload.headline_asset
82 assert_includes @node.draft.assets, @image
83 end
84
85 test "declines the headline flag for ineligible asset types" do
86 plain = create_plain_asset
87 result = @node.attach_asset!(plain, :user => @user, :headline => true)
88 assert_equal :not_eligible, result[:headline]
89 assert_includes @node.draft.assets.reload, plain
90 assert_nil @node.draft.headline_asset
91 end
92
93 test "refuses nodes in the Trash" do
94 @node.trash!(@user)
95 assert_raises(ActiveRecord::RecordInvalid) { @node.attach_asset!(@image, :user => @user) }
96 end
97
98 private
99
100 def create_image_asset
101 Asset.create!(:name => "attach test image", :upload_content_type => "image/png")
102 end
103
104 def create_plain_asset
105 Asset.create!(:name => "attach test note", :upload_content_type => "text/plain")
106 end
107end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index d7e4dd0..ba38340 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -546,30 +546,6 @@ class NodeTest < ActiveSupport::TestCase
546 assert result.index(mine) < result.index(someone_elses_newer) 546 assert result.index(mine) < result.index(someone_elses_newer)
547 end 547 end
548 548
549 test "recently_changed includes a node whose head was recently published" do
550 node = Node.root.children.create!(:slug => "recent_changed_published")
551 find_or_create_draft(node, @user1)
552 node.autosave!({:title => "v1"}, @user1)
553 node.save_draft!(@user1)
554 node.publish_draft!
555
556 assert_includes Node.recently_changed, node
557 end
558
559 test "recently_changed excludes a node only touched by locking or unlocking after an old publish" do
560 node = Node.root.children.create!(:slug => "recent_changed_lock_only")
561 find_or_create_draft(node, @user1)
562 node.autosave!({:title => "v1"}, @user1)
563 node.save_draft!(@user1)
564 node.publish_draft!
565 node.head.update_column(:updated_at, 20.days.ago)
566
567 node.lock_for_editing!(@user1)
568 node.unlock!
569
570 assert_not_includes Node.recently_changed, node
571 end
572
573 test "autosave! carries over the current related assets to the newly created autosave row" do 549 test "autosave! carries over the current related assets to the newly created autosave row" do
574 node = Node.root.children.create!(:slug => "autosave_asset_carryover_test") 550 node = Node.root.children.create!(:slug => "autosave_asset_carryover_test")
575 user = User.find_by_login("quentin") 551 user = User.find_by_login("quentin")