Refactor, refresh UI and unify the structure of journals, replies and comments (#42972, #40744).

git-svn-id: https://svn.redmine.org/redmine/trunk@23887 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Marius Balteanu
2025-07-14 21:33:33 +00:00
parent 36446be97c
commit e940540e2e
14 changed files with 257 additions and 182 deletions

View File

@@ -426,7 +426,7 @@ function showIssueHistory(journal, url) {
tab_content.find('.journal').show();
tab_content.find('.journal:not(.has-notes)').hide();
tab_content.find('.journal .wiki').show();
tab_content.find('.journal .contextual .journal-actions > *').show();
tab_content.find('.journal .journal-actions > *').show();
// always show thumbnails in notes tab
var thumbnails = tab_content.find('.journal .thumbnails');
@@ -439,15 +439,15 @@ function showIssueHistory(journal, url) {
tab_content.find('.journal:not(.has-details)').hide();
tab_content.find('.journal .wiki').hide();
tab_content.find('.journal .thumbnails').hide();
tab_content.find('.journal .contextual .journal-actions > *').hide();
tab_content.find('.journal .journal-actions > *').hide();
// Show reaction button in properties tab
tab_content.find('.journal .contextual .journal-actions .reaction-button-wrapper').show();
tab_content.find('.journal .journal-actions .reaction-button-wrapper').show();
break;
default:
tab_content.find('.journal').show();
tab_content.find('.journal .wiki').show();
tab_content.find('.journal .thumbnails').show();
tab_content.find('.journal .contextual .journal-actions > *').show();
tab_content.find('.journal .journal-actions > *').show();
}
return false;

View File

@@ -306,11 +306,31 @@ div + .drdn-items {border-top:1px solid #ccc;}
}
.drdn-items>span {color:#999;}
.contextual .drdn-content {top:18px;}
.contextual .drdn-items {padding:2px; min-width: 160px;}
.contextual .drdn-items>a {display: flex; padding: 5px 8px;}
.contextual .drdn-items>a.icon:not(:has(svg)) {padding-left: 24px; background-position-x: 4px;}
.contextual .drdn-items>a:hover {color:#2A5685; border:1px solid #628db6; background-color:#eef5fd; border-radius:3px;}
.contextual .drdn-content, .journal-actions .drdn-content {
top: 18px;
}
.contextual .drdn-items, .journal-actions .drdn-items {
padding: 2px;
min-width: 160px;
}
.contextual .drdn-items > a, .journal-actions .drdn-items > a {
display: flex;
padding: 5px 8px;
}
.contextual .drdn-items > a.icon:not(:has(svg)), .journal-actions .drdn-items > a.icon:not(:has(svg)) {
padding-left: 24px;
background-position-x: 4px;
}
.contextual .drdn-items > a:hover, .journal-actions .drdn-items > a:hover {
color: #2A5685;
border: 1px solid #628db6;
background-color: #eef5fd;
border-radius: 3px;
}
#project-jump.drdn {width:200px;display:inline-block;}
#project-jump .drdn-trigger {
@@ -436,10 +456,6 @@ tr.message td.last_message { font-size: 93%; white-space: nowrap; }
tr.message.sticky td.subject { font-weight: bold; }
tr.message td.subject:not(:has(.icon)) { padding-left: 20px; }
body.avatars-on #replies .message.reply {padding-left: 32px;}
#replies .reply:target h4.reply-header {background-color:#DDEEFF;}
#replies h4 img.gravatar {margin-left:-32px;}
tr.version.closed, tr.version.closed a { color: #999; }
tr.version:not(.shared) td.name { padding-left: 20px; }
tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
@@ -593,7 +609,6 @@ div.square {
}
.contextual {float:right; white-space: nowrap; line-height:1.4em;margin:5px 0px; padding-left: 10px; font-size:0.9em;}
.contextual input, .contextual select {font-size:0.9em;}
.message .contextual, #comments .contextual { margin-top: 0; }
.splitcontent {overflow: auto; display: flex; flex-wrap: wrap;}
.splitcontentleft {flex: 1; margin-right: 5px;}
@@ -775,28 +790,8 @@ div#issue-changesets div.changeset {border-bottom: 1px solid #ddd; padding: 4px;
div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
.changeset-comments {margin-bottom:1em;}
div.journal .contextual {margin-top: 0;}
div.journal.private-notes .wiki {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
div.journal ul.details, ul.revision-info {color:#959595; margin-bottom: 1.5em;}
div.journal ul.details a, ul.revision-info a {color:#70A7CD;}
div.journal ul.details a:hover, ul.revision-info a:hover {color:#D14848;}
body.avatars-on div.journal {padding-left:32px;}
div.journal h4 img.gravatar {margin-left:-32px;}
div.journal span.update-info {color: #666; font-size: 0.9em;}
#update {margin-bottom: 1.4em;}
#history .tab-content {
padding: 0 8px;
margin-bottom: 10px;
border-right: 1px solid #d0d7de;
border-bottom: 1px solid #d0d7de;
border-left: 1px solid #d0d7de;
border-radius: 0 0 3px 3px / 0 0 3px 3px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
#history div:target h4.note-header {background-color:#DDEEFF;}
#history p.nodata {display: none;}
/* Prevent content from being hidden behind a #sticky-issue-header when scrolling via anchor links. */
.controller-issues.action-show div.wiki a[name],
@@ -1193,7 +1188,6 @@ div.attachments span.author { font-size: 0.9em; color: #888; }
div.thumbnails {margin:0.6em;}
div.thumbnail {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
div.thumbnail img {margin: 3px; vertical-align: middle;}
#history div.thumbnails {margin-left: 2em;}
p.other-formats { text-align: right; font-size:0.9em; color: #666; }
.other-formats span + span:before { content: "| "; }
@@ -1745,6 +1739,7 @@ div.wiki .task-list input.task-list-item-checkbox {
.handle {cursor: move;}
#my-page .list th.checkbox, #my-page .list td.checkbox {display:none;}
/***** Gantt chart *****/
table.gantt-table {
width: 100%;
@@ -1858,6 +1853,68 @@ td.gantt_selected_column .gantt_hdr,.gantt_selected_column_container {
.version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
.version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
/***** User events (ex: journal, notes, replies, comments) *****/
.journals h4.journal-header {
background-color: #f6f7f8;
border-bottom: 0;
padding: 8px;
align-items: center;
display: flex;
justify-content: space-between;
}
.journals h4.journal-header .update-info {
color: #666;
font-size: 0.9em;
}
.journals h4.journal-header .badge {
position: static;
}
.journals div:target h4.journal-header {
background-color:#DDEEFF;
}
.journals .journal-content {
padding-left: 8px;
margin-bottom: 1.2em;
}
.journals .journal .journal-content .wiki {
margin-left: 0.6em;
}
.journals .private-notes {
border-left: 2px solid #d22;
}
.journals .journal-meta, .journals .journal-actions {
display: inline-flex;
gap: 10px;
}
.journals .journal-meta .journal-link {
color: #555;
}
.journals .journal-actions .reaction-button-wrapper {
display: inline-flex;
}
.journals .journal-details, ul.revision-info {
color: #959595;
margin-bottom: 1.5em;
}
.journals .journal-details a, ul.revision-info a {
color: #70A7CD;
}
.journals .journal-details a:hover, ul.revision-info a:hover {
color: #D14848;
}
/***** Badges *****/
.badge {
position:relative;
@@ -2135,7 +2192,6 @@ div.gravatar-with-child > img.gravatar:nth-child(2) {
}
h2 img.gravatar, h3 img.gravatar {margin-right: 4px;}
h4 img.gravatar {margin: -2px 4px -4px 0;}
td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
#activity dt img.gravatar {margin: 0 1em 0 0;}
/* Used on 12px Gravatar img tags without the icon background */

View File

@@ -70,7 +70,7 @@ module JournalsHelper
def render_notes(issue, journal, options={})
content_tag('div', textilizable(journal, :notes),
id: "journal-#{journal.id}-notes", class: "wiki", data: { quote_reply_target: 'content' })
id: "journal-#{journal.id}-notes", class: "wiki journal-note", data: { quote_reply_target: 'content' })
end
def render_private_notes_indicator(journal)

View File

@@ -131,7 +131,7 @@ end %>
<%= render partial: 'action_menu_edit' if User.current.wants_comments_in_reverse_order? %>
<div id="history">
<div id="history" class="journals">
<%= render_tabs issue_history_tabs, issue_history_default_tab %>
</div>

View File

@@ -1,10 +1,12 @@
<% @changesets.each do |changeset| %>
<div id="changeset-<%= changeset.id %>" class="changeset journal">
<div class="note">
<h4 class='note-header'>
<h4 class="journal-header">
<span class="journal-info">
<%= avatar(changeset.user, :size => "24") %>
<%= authoring changeset.committed_on, changeset.author, :label => :label_added_time_by %>
</span>
</h4>
<div class="journal-content">
<p>
<%= "#{changeset.project.name} - " unless changeset.project == project %>
<%= link_to_revision(changeset, changeset.repository,
@@ -17,8 +19,8 @@
:repository_id => changeset.repository.identifier_param,
:path => "",
:rev => changeset.identifier) %>)
<% end %></p>
<% end %>
</p>
<div class="wiki changeset-comments">
<%= format_changeset_comments changeset %>
</div>

View File

@@ -7,19 +7,23 @@
<% for journal in journals %>
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>" data-controller="quote-reply">
<div id="note-<%= journal.indice %>" class="note">
<div class="contextual">
<span class="journal-actions"><%= render_journal_actions(issue, journal, :reply_links => reply_links) %></span>
<a href="#note-<%= journal.indice %>" class="journal-link">#<%= journal.indice %></a>
</div>
<h4 class='note-header'>
<h4 class="journal-header">
<span class="journal-info">
<%= avatar(journal.user) %>
<%= authoring journal.created_on, journal.user, :label => :label_updated_time_by %>
<%= render_private_notes_indicator(journal) %>
<%= render_journal_update_info(journal) %>
</span>
<span class="journal-meta">
<span class="journal-actions">
<%= render_journal_actions(issue, journal, :reply_links => reply_links) %>
</span>
<a href="#note-<%= journal.indice %>" class="journal-link">#<%= journal.indice %></a>
</span>
</h4>
<div class="journal-content">
<% if journal.details.any? %>
<ul class="details">
<ul class="journal-details">
<% details_to_strings(journal.visible_details).each do |string| %>
<li><%= string %></li>
<% end %>
@@ -35,6 +39,7 @@
<%= render_notes(issue, journal, :reply_links => reply_links) unless journal.notes.blank? %>
</div>
</div>
</div>
<%= call_hook(:view_issues_history_journal_bottom, { :journal => journal }) %>
<% end %>

View File

@@ -1,31 +1,33 @@
<% for time_entry in time_entries%>
<% for time_entry in time_entries %>
<div id="time-entry-<%= time_entry.id %>" class="time_entry journal">
<div class="note">
<% if time_entry.editable_by?(User.current) -%>
<div class="contextual">
<span class="journal-actions">
<%= link_to sprite_icon('edit', l(:button_edit)), edit_time_entry_path(time_entry),
:title => l(:button_edit),
:class => 'icon-only icon-edit ' %>
<%= link_to sprite_icon('del', l(:button_delete)), time_entry_path(time_entry),
:data => {:confirm => l(:text_are_you_sure)},
:method => :delete,
:title => l(:button_delete),
:class => 'icon-only icon-del ' %>
</span>
</div>
<% end -%>
<h4 class='note-header'>
<h4 class="journal-header">
<span class="journal-info">
<%= avatar(time_entry.user, :size => "24") %>
<%= authoring time_entry.created_on, time_entry.user, :label => :label_added_time_by %>
</span>
<% if time_entry.editable_by?(User.current) -%>
<span class="journal-meta">
<%= link_to sprite_icon('edit', l(:button_edit)), edit_time_entry_path(time_entry),
:title => l(:button_edit),
:class => 'icon-only icon-edit' %>
<%= link_to sprite_icon('del', l(:button_delete)), time_entry_path(time_entry),
:data => { :confirm => l(:text_are_you_sure) },
:method => :delete,
:title => l(:button_delete),
:class => 'icon-only icon-del' %>
</span>
<% end -%>
</h4>
<ul class="details">
<div class="journal-content">
<ul class="journal-details">
<li>
<strong><%= l(:label_time_entry_plural) %></strong>:
<%= l_hours_short time_entry.hours %>
</li>
</ul>
<p><%= time_entry.comments %></p>
<div class="journal-note">
<%= time_entry.comments %>
</div>
</div>
</div>
<%= call_hook(:view_issues_history_time_entry_bottom, { :time_entry => time_entry }) %>

View File

@@ -7,7 +7,7 @@
$("#journal-<%= @journal.id %>-notes").replaceWith('<%= escape_javascript(render_notes(@journal.issue, @journal, :reply_links => authorize_for('issues', 'edit'))) %>');
$("#journal-<%= @journal.id %>-notes").show();
$("#journal-<%= @journal.id %>-form").remove();
var journal_header = $("#change-<%= @journal.id %>>div.note>h4.note-header");
var journal_header = $("#change-<%= @journal.id %>>div.note>h4.journal-header>.journal-info");
var journal_updated_info = journal_header.find("span.update-info");
if (journal_updated_info.length > 0) {
journal_updated_info.replaceWith('<%= escape_javascript(render_journal_update_info(@journal)) %>');

View File

@@ -8,14 +8,14 @@
) if !@topic.locked? && authorize_for('messages', 'reply') %>
<%= link_to(
sprite_icon('edit', l(:button_edit)),
{:action => 'edit', :id => @topic},
{ :action => 'edit', :id => @topic },
:class => 'icon icon-edit'
) if @message.editable_by?(User.current) %>
<%= link_to(
sprite_icon('del', l(:button_delete)),
{:action => 'destroy', :id => @topic},
{ :action => 'destroy', :id => @topic },
:method => :post,
:data => {:confirm => l(:text_are_you_sure)},
:data => { :confirm => l(:text_are_you_sure) },
:class => 'icon icon-del'
) if @message.destroyable_by?(User.current) %>
</div>
@@ -33,17 +33,25 @@
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
</div>
</div>
<br />
<br/>
<% unless @replies.empty? %>
<div id="replies">
<h3 class="comments icon icon-comments"><%= sprite_icon('comments', l(:label_reply_plural)) %> (<%= @reply_count %>)</h3>
<% if !@topic.locked? && authorize_for('messages', 'reply') && @replies.size >= 3 %>
<div id="replies" class="journals">
<h3 class="comments icon icon-comments"><%= sprite_icon('comments', l(:label_reply_plural)) %>
(<%= @reply_count %>)</h3>
<% if !@topic.locked? && authorize_for('messages', 'reply') && @replies.size >= 3 %>
<p><%= toggle_link l(:button_reply), "reply", :focus => 'message_content', :scroll => "message_content" %></p>
<% end %>
<% @replies.each do |message| %>
<div class="message reply" id="<%= "message-#{message.id}" %>" data-controller="quote-reply">
<div class="contextual">
<% end %>
<% @replies.each do |message| %>
<div class="message reply journal" id="<%= "message-#{message.id}" %>" data-controller="quote-reply">
<h4 class='reply-header journal-header'>
<span class="journal-info">
<%= avatar(message.author) %>
<%= link_to message.subject, { :controller => 'messages', :action => 'show', :board_id => @board, :id => @topic, :r => message, :anchor => "message-#{message.id}" } %>
-
<%= authoring message.created_on, message.author %>
</span>
<span class="journal-meta">
<%= reaction_button message %>
<%= quote_reply_button(
url: url_for(action: 'quote', id: message, format: 'js'),
@@ -51,43 +59,38 @@
) if !@topic.locked? && authorize_for('messages', 'reply') %>
<%= link_to(
sprite_icon('edit', l(:button_edit), icon_only: true),
{:action => 'edit', :id => message},
{ :action => 'edit', :id => message },
:title => l(:button_edit),
:class => 'icon icon-edit'
) if message.editable_by?(User.current) %>
<%= link_to(
sprite_icon('del', l(:button_delete), icon_only: true),
{:action => 'destroy', :id => message},
{ :action => 'destroy', :id => message },
:method => :post,
:data => {:confirm => l(:text_are_you_sure)},
:data => { :confirm => l(:text_are_you_sure) },
:title => l(:button_delete),
:class => 'icon icon-del'
) if message.destroyable_by?(User.current) %>
</div>
<h4 class='reply-header'>
<%= avatar(message.author) %>
<%= link_to message.subject, { :controller => 'messages', :action => 'show', :board_id => @board, :id => @topic, :r => message, :anchor => "message-#{message.id}" } %>
-
<%= authoring message.created_on, message.author %>
</span>
</h4>
<div class="wiki" data-quote-reply-target="content">
<div class="wiki journal-content" data-quote-reply-target="content">
<%= textilizable message, :content, :attachments => message.attachments %>
</div>
<%= link_to_attachments message, :author => false, :thumbnails => true %>
</div>
<% end %>
</div>
<span class="pagination"><%= pagination_links_full @reply_pages, @reply_count, :per_page_links => false %></span>
<% end %>
</div>
<span class="pagination"><%= pagination_links_full @reply_pages, @reply_count, :per_page_links => false %></span>
<% end %>
<% if !@topic.locked? && authorize_for('messages', 'reply') %>
<p><%= toggle_link l(:button_reply), "reply", :focus => 'message_content' %></p>
<div id="reply" style="display:none;">
<%= form_for @reply, :as => :reply, :url => {:action => 'reply', :id => @topic}, :html => {:multipart => true, :id => 'message-form'} do |f| %>
<%= render :partial => 'form', :locals => {:f => f, :replying => true} %>
<p><%= toggle_link l(:button_reply), "reply", :focus => 'message_content' %></p>
<div id="reply" style="display:none;">
<%= form_for @reply, :as => :reply, :url => { :action => 'reply', :id => @topic }, :html => { :multipart => true, :id => 'message-form' } do |f| %>
<%= render :partial => 'form', :locals => { :f => f, :replying => true } %>
<%= submit_tag l(:button_submit) %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% html_title @topic.subject %>
@@ -95,7 +98,7 @@
<% if User.current.allowed_to?(:add_message_watchers, @project) ||
(@topic.watchers.present? && User.current.allowed_to?(:view_message_watchers, @project)) %>
<div id="watchers">
<%= render :partial => 'watchers/watchers', :locals => {:watched => @topic} %>
<%= render :partial => 'watchers/watchers', :locals => { :watched => @topic } %>
</div>
<% end %>
<% end %>

View File

@@ -35,24 +35,31 @@
</div>
<br />
<div id="comments" style="margin-bottom:16px;">
<div id="comments" class="journals">
<h3 class="comments"><%= l(:label_comment_plural) %></h3>
<% if @news.commentable? && @comments.size >= 3 %>
<p><%= toggle_link l(:label_comment_add), "add_comment_form", :focus => "comment_comments", :scroll => "comment_comments" %></p>
<% end %>
<% @comments.each do |comment| %>
<div class="message reply journal-entry" id="<%= "message-#{comment.id}" %>">
<% next if comment.new_record? %>
<div class="contextual">
<h4 class="reply-header journal-header">
<span class="journal-info">
<%= avatar(comment.author) %>
<%= authoring comment.created_on, comment.author %>
</span>
<span class="journal-meta">
<%= reaction_button comment %>
<%= link_to_if_authorized sprite_icon('del', l(:button_delete)), { :controller => 'comments', :action => 'destroy', :id => @news, :comment_id => comment},
:data => {:confirm => l(:text_are_you_sure)}, :method => :delete,
:title => l(:button_delete),
:class => 'icon-only icon-del' %>
</div>
<h4><%= avatar(comment.author) %><%= authoring comment.created_on, comment.author %></h4>
<div class="wiki">
</span>
</h4>
<div class="wiki journal-content">
<%= textilizable(comment.comments) %>
</div>
</div>
<% end if @comments.any? %>
</div>

View File

@@ -2485,7 +2485,7 @@ class IssuesControllerTest < Redmine::ControllerTest
end
assert_select 'div#tab-content-history' do
assert_select 'div[id=?]', "change-#{Issue.find(1).journals.last.id}" do
assert_select 'ul.details', :text => "Subtask ##{issue.id} added"
assert_select 'ul.journal-details', :text => "Subtask ##{issue.id} added"
end
end
end
@@ -3305,7 +3305,7 @@ class IssuesControllerTest < Redmine::ControllerTest
assert_select 'a[title=?][href=?]', 'Edit', '/time_entries/3/edit'
assert_select 'a[title=?][href=?]', 'Delete', '/time_entries/3'
assert_select 'ul[class=?]', 'details', :text => /1.00 h/
assert_select 'ul[class=?]', 'journal-details', :text => /1.00 h/
end
end
@@ -8697,7 +8697,7 @@ class IssuesControllerTest < Redmine::ControllerTest
assert_select 'div#tab-content-history' do
assert_select 'div[id=?]', "change-#{parent.journals.last.id}" do
assert_select 'ul.details', :text => "Subtask deleted (##{child.id})"
assert_select 'ul.journal-details', :text => "Subtask deleted (##{child.id})"
end
end
end

View File

@@ -101,9 +101,9 @@ class IssuesCustomFieldsVisibilityTest < Redmine::ControllerTest
get(:show, :params => {:id => @issue.id})
@fields.each_with_index do |field, i|
if fields.include?(field)
assert_select 'ul.details i', {:text => "Value#{i}", :count => 1}, "User #{user.id} was not able to view #{field.name} change"
assert_select 'ul.journal-details i', {:text => "Value#{i}", :count => 1}, "User #{user.id} was not able to view #{field.name} change"
else
assert_select 'ul.details i', {:text => "Value#{i}", :count => 0}, "User #{user.id} was able to view #{field.name} change"
assert_select 'ul.journal-details i', {:text => "Value#{i}", :count => 0}, "User #{user.id} was able to view #{field.name} change"
end
end
end

View File

@@ -355,7 +355,7 @@ class IssuesTest < Redmine::IntegrationTest
end
# Issue view
follow_redirect!
assert_select 'ul.details li', :text => "Tester changed from #{tester} to #{new_tester}"
assert_select 'ul.journal-details li', :text => "Tester changed from #{tester} to #{new_tester}"
end
end

View File

@@ -93,7 +93,7 @@ class IssuesReplyTest < ApplicationSystemTestCase
# Select the entire details of the note#1 and the part of the note#1's text.
page.execute_script <<-JS
const range = document.createRange();
range.setStartBefore(document.querySelector('#change-1 .details'));
range.setStartBefore(document.querySelector('#change-1 .journal-details'));
// Select only the text "Journal" from the text "Journal notes" in the note-1.
range.setEnd(document.querySelector('#change-1 .wiki > p').childNodes[0], 7);