summaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/chevron-down.svg1
-rw-r--r--app/assets/images/chevron-right-idnt.svg1
-rw-r--r--app/assets/images/hourglass-empty.svg1
-rw-r--r--app/assets/images/icons.svg60
-rw-r--r--app/assets/javascripts/application-legacy.js (renamed from app/assets/javascripts/application.js)133
-rw-r--r--app/assets/javascripts/attachments.js14
-rw-r--r--app/assets/javascripts/quote_reply.js44
-rw-r--r--app/assets/javascripts/turndown-7.2.0.min.js8
-rw-r--r--app/assets/stylesheets/application.css433
-rw-r--r--app/assets/stylesheets/context_menu.css4
-rw-r--r--app/assets/stylesheets/context_menu_rtl.css11
-rw-r--r--app/assets/stylesheets/responsive.css35
-rw-r--r--app/assets/stylesheets/rtl.css70
-rw-r--r--app/assets/stylesheets/scm.css42
-rw-r--r--app/assets/stylesheets/wiki_syntax.css49
-rw-r--r--app/assets/stylesheets/wiki_syntax_detailed.css58
-rw-r--r--app/controllers/application_controller.rb8
-rw-r--r--app/controllers/auto_completes_controller.rb2
-rw-r--r--app/controllers/context_menus_controller.rb2
-rw-r--r--app/controllers/messages_controller.rb4
-rw-r--r--app/controllers/news_controller.rb4
-rw-r--r--app/controllers/oauth2_applications_controller.rb38
-rw-r--r--app/controllers/previews_controller.rb6
-rw-r--r--app/controllers/projects_controller.rb2
-rw-r--r--app/controllers/reactions_controller.rb65
-rw-r--r--app/controllers/repositories_controller.rb10
-rw-r--r--app/controllers/roles_controller.rb10
-rw-r--r--app/controllers/versions_controller.rb4
-rw-r--r--app/controllers/wiki_controller.rb1
-rw-r--r--app/helpers/application_helper.rb30
-rw-r--r--app/helpers/avatars_helper.rb1
-rw-r--r--app/helpers/icons_helper.rb45
-rw-r--r--app/helpers/issues_helper.rb107
-rw-r--r--app/helpers/journals_helper.rb8
-rw-r--r--app/helpers/messages_helper.rb1
-rw-r--r--app/helpers/news_helper.rb1
-rw-r--r--app/helpers/principal_memberships_helper.rb18
-rw-r--r--app/helpers/projects_helper.rb4
-rw-r--r--app/helpers/queries_helper.rb4
-rw-r--r--app/helpers/reactions_helper.rb100
-rw-r--r--app/helpers/reports_helper.rb4
-rw-r--r--app/helpers/repositories_helper.rb4
-rw-r--r--app/helpers/routes_helper.rb62
-rw-r--r--app/helpers/settings_helper.rb9
-rw-r--r--app/helpers/watchers_helper.rb7
-rw-r--r--app/javascript/application.js1
-rw-r--r--app/javascript/controllers/application.js8
-rw-r--r--app/javascript/controllers/index.js3
-rw-r--r--app/javascript/controllers/quote_reply_controller.js224
-rw-r--r--app/javascript/controllers/sticky_issue_header_controller.js22
-rw-r--r--app/models/comment.rb8
-rw-r--r--app/models/custom_field.rb7
-rw-r--r--app/models/email_address.rb6
-rw-r--r--app/models/group.rb4
-rw-r--r--app/models/issue.rb28
-rw-r--r--app/models/issue_relation.rb4
-rw-r--r--app/models/journal.rb5
-rw-r--r--app/models/mail_handler.rb4
-rw-r--r--app/models/member.rb4
-rw-r--r--app/models/message.rb2
-rw-r--r--app/models/news.rb2
-rw-r--r--app/models/principal.rb7
-rw-r--r--app/models/project.rb8
-rw-r--r--app/models/reaction.rb60
-rw-r--r--app/models/repository.rb8
-rw-r--r--app/models/repository/bazaar.rb4
-rw-r--r--app/models/repository/cvs.rb4
-rw-r--r--app/models/repository/filesystem.rb4
-rw-r--r--app/models/repository/git.rb4
-rw-r--r--app/models/repository/mercurial.rb4
-rw-r--r--app/models/role.rb26
-rw-r--r--app/models/time_entry.rb10
-rw-r--r--app/models/time_entry_query.rb19
-rw-r--r--app/models/user.rb50
-rw-r--r--app/models/user_preference.rb2
-rw-r--r--app/models/version.rb10
-rw-r--r--app/views/activities/_activities.html.erb2
-rw-r--r--app/views/attachments/_form.html.erb9
-rw-r--r--app/views/attachments/other.html.erb1
-rw-r--r--app/views/calendars/show.html.erb2
-rw-r--r--app/views/common/_tabs.html.erb8
-rw-r--r--app/views/common/error.html.erb5
-rw-r--r--app/views/context_menus/issues.html.erb9
-rw-r--r--app/views/context_menus/time_entries.html.erb2
-rw-r--r--app/views/custom_fields/formats/_progressbar.html.erb6
-rw-r--r--app/views/custom_fields/index.api.rsb1
-rw-r--r--app/views/doorkeeper/applications/_form.html.erb39
-rw-r--r--app/views/doorkeeper/applications/edit.html.erb6
-rw-r--r--app/views/doorkeeper/applications/index.html.erb33
-rw-r--r--app/views/doorkeeper/applications/new.html.erb6
-rw-r--r--app/views/doorkeeper/applications/show.html.erb54
-rw-r--r--app/views/doorkeeper/authorizations/error.html.erb6
-rw-r--r--app/views/doorkeeper/authorizations/new.html.erb48
-rw-r--r--app/views/doorkeeper/authorizations/show.html.erb8
-rw-r--r--app/views/doorkeeper/authorized_applications/index.html.erb31
-rw-r--r--app/views/gantts/show.html.erb8
-rw-r--r--app/views/help/wiki_syntax/common_mark/en/wiki_syntax_common_mark.html.erb8
-rw-r--r--app/views/help/wiki_syntax/common_mark/en/wiki_syntax_detailed_common_mark.html.erb52
-rw-r--r--app/views/imports/_issues_mapping.html.erb2
-rw-r--r--app/views/issue_relations/_form.html.erb1
-rw-r--r--app/views/issues/_action_menu.html.erb2
-rw-r--r--app/views/issues/_conflict.html.erb1
-rw-r--r--app/views/issues/_edit.html.erb2
-rw-r--r--app/views/issues/_list.html.erb4
-rw-r--r--app/views/issues/bulk_edit.html.erb2
-rw-r--r--app/views/issues/index.html.erb2
-rw-r--r--app/views/issues/show.html.erb28
-rw-r--r--app/views/issues/tabs/_changesets.html.erb44
-rw-r--r--app/views/issues/tabs/_history.html.erb55
-rw-r--r--app/views/issues/tabs/_time_entries.html.erb52
-rw-r--r--app/views/journals/update.js.erb4
-rw-r--r--app/views/layouts/base.html.erb5
-rw-r--r--app/views/messages/show.html.erb143
-rw-r--r--app/views/my/account.html.erb1
-rw-r--r--app/views/news/show.html.erb45
-rw-r--r--app/views/projects/index.html.erb2
-rw-r--r--app/views/queries/_filters.html.erb1
-rw-r--r--app/views/queries/_form.html.erb2
-rw-r--r--app/views/queries/_query_form.html.erb4
-rw-r--r--app/views/reactions/_replace_button.js.erb7
-rw-r--r--app/views/reactions/create.js.erb1
-rw-r--r--app/views/reactions/destroy.js.erb1
-rw-r--r--app/views/repositories/_breadcrumbs.html.erb2
-rw-r--r--app/views/repositories/_dir_list_content.html.erb2
-rw-r--r--app/views/repositories/annotate.html.erb5
-rw-r--r--app/views/repositories/revision.html.erb10
-rw-r--r--app/views/roles/permissions.html.erb2
-rw-r--r--app/views/search/index.html.erb2
-rw-r--r--app/views/settings/_display.html.erb22
-rw-r--r--app/views/settings/_general.html.erb2
-rw-r--r--app/views/settings/_issues.html.erb19
-rw-r--r--app/views/settings/_timelog.html.erb2
-rw-r--r--app/views/timelog/_list.html.erb4
-rw-r--r--app/views/timelog/bulk_edit.html.erb1
-rw-r--r--app/views/timelog/index.html.erb2
-rw-r--r--app/views/users/show.api.rsb2
-rw-r--r--app/views/versions/_sidebar.html.erb4
-rw-r--r--app/views/versions/index.html.erb2
-rw-r--r--app/views/versions/show.html.erb2
-rw-r--r--app/views/wiki/date_index.html.erb2
-rw-r--r--app/views/wiki/index.html.erb8
-rw-r--r--app/views/wiki/show.html.erb2
-rw-r--r--app/views/workflows/edit.html.erb8
143 files changed, 2301 insertions, 636 deletions
diff --git a/app/assets/images/chevron-down.svg b/app/assets/images/chevron-down.svg
new file mode 100644
index 000000000..7dfc75f54
--- /dev/null
+++ b/app/assets/images/chevron-down.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ccd" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 9l6 6l6 -6" /></svg> \ No newline at end of file
diff --git a/app/assets/images/chevron-right-idnt.svg b/app/assets/images/chevron-right-idnt.svg
new file mode 100644
index 000000000..c15529e90
--- /dev/null
+++ b/app/assets/images/chevron-right-idnt.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ccd" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg> \ No newline at end of file
diff --git a/app/assets/images/hourglass-empty.svg b/app/assets/images/hourglass-empty.svg
new file mode 100644
index 000000000..789eb193c
--- /dev/null
+++ b/app/assets/images/hourglass-empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#169" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-hourglass-empty"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 20v-2a6 6 0 1 1 12 0v2a1 1 0 0 1 -1 1h-10a1 1 0 0 1 -1 -1z" /><path d="M6 4v2a6 6 0 1 0 12 0v-2a1 1 0 0 0 -1 -1h-10a1 1 0 0 0 -1 1z" /></svg> \ No newline at end of file
diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg
index 55925cddd..6283537ce 100644
--- a/app/assets/images/icons.svg
+++ b/app/assets/images/icons.svg
@@ -11,9 +11,17 @@
<path d="M9 12h6"/>
<path d="M12 9v6"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--alert-circle">
+ <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/>
+ <path d="M12 8v4"/>
+ <path d="M12 16h.01"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--angle-down">
<path d="M6 9l6 6l6 -6"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--angle-left">
+ <path d="M15 6l-6 6l6 6"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--angle-right">
<path d="M9 6l6 6l-6 6"/>
</symbol>
@@ -51,6 +59,13 @@
<path d="M12 15v6"/>
<path d="M5 15h3l-3 6h3"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--apps">
+ <path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
+ <path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
+ <path d="M14 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
+ <path d="M14 7l6 0"/>
+ <path d="M17 4l0 6"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--arrow-right">
<path d="M4 9h8v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-8a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1z"/>
</symbol>
@@ -69,6 +84,11 @@
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--bookmarked">
<path d="M18 7v14l-6 -4l-6 4v-14a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4z"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--bulb">
+ <path d="M3 12h1m8 -9v1m8 8h1m-15.4 -6.4l.7 .7m12.1 -.7l-.7 .7"/>
+ <path d="M9 16a5 5 0 1 1 6 0a3.5 3.5 0 0 0 -1 3a2 2 0 0 1 -4 0a3.5 3.5 0 0 0 -1 -3"/>
+ <path d="M9.7 17l4.6 0"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--bullet-end">
<path d="M12 21a9 9 0 1 0 0 -18a9 9 0 0 0 0 18"/>
<path d="M8 12l4 4"/>
@@ -104,6 +124,13 @@
<path d="M7 7l5 5l-5 5"/>
<path d="M13 7l5 5l-5 5"/>
</symbol>
+ <symbol viewBox="0 0 24 24" id="icon--circle-dot-filled">
+ <path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-5 6.66a2 2 0 0 0 -1.977 1.697l-.018 .154l-.005 .149l.005 .15a2 2 0 1 0 1.995 -2.15z"/>
+ </symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--circle-minus">
+ <path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/>
+ <path d="M9 12l6 0"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--clear-query">
<path d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14z"/>
<path d="M9 9l6 6m0 -6l-6 6"/>
@@ -131,6 +158,10 @@
<path d="M13 17v-1a1 1 0 0 1 1 -1h1m3 0h1a1 1 0 0 1 1 1v1m0 3v1a1 1 0 0 1 -1 1h-1m-3 0h-1a1 1 0 0 1 -1 -1v-1"/>
<path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--copy-pre-content">
+ <path d="M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2"/>
+ <path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--custom-fields">
<path d="M20 13v-4a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v5a2 2 0 0 0 2 2h6"/>
<path d="M15 19l2 2l4 -4"/>
@@ -280,6 +311,11 @@
<path d="M8 13h6"/>
<path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--message-report">
+ <path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z"/>
+ <path d="M12 8v3"/>
+ <path d="M12 14v.01"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--move">
<path d="M15 14l4 -4l-4 -4"/>
<path d="M19 10h-11a4 4 0 1 0 0 8h1"/>
@@ -322,6 +358,10 @@
<path d="M7 5.03v5.455"/>
<path d="M12 8l5 -3"/>
</symbol>
+ <symbol viewBox="0 0 24 24" id="icon--quote-filled">
+ <path d="M9 5a2 2 0 0 1 2 2v6c0 3.13 -1.65 5.193 -4.757 5.97a1 1 0 1 1 -.486 -1.94c2.227 -.557 3.243 -1.827 3.243 -4.03v-1h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 2 -2z"/>
+ <path d="M18 5a2 2 0 0 1 2 2v6c0 3.13 -1.65 5.193 -4.757 5.97a1 1 0 1 1 -.486 -1.94c2.227 -.557 3.243 -1.827 3.243 -4.03v-1h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 2 -2z"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--reload">
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/>
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>
@@ -365,6 +405,10 @@
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--shield-check">
+ <path d="M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06"/>
+ <path d="M15 19l2 2l4 -4"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--stats">
<path d="M3 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
<path d="M15 9a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
@@ -445,6 +489,13 @@
<path d="M19 15v6h3"/>
<path d="M11 21v-6l2.5 3l2.5 -3v6"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--thumb-up">
+ <path d="M7 11v8a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-7a1 1 0 0 1 1 -1h3a4 4 0 0 0 4 -4v-1a2 2 0 0 1 4 0v5h3a2 2 0 0 1 2 2l-1 5a2 3 0 0 1 -2 2h-7a3 3 0 0 1 -3 -3"/>
+ </symbol>
+ <symbol viewBox="0 0 24 24" id="icon--thumb-up-filled">
+ <path d="M13 3a3 3 0 0 1 2.995 2.824l.005 .176v4h2a3 3 0 0 1 2.98 2.65l.015 .174l.005 .176l-.02 .196l-1.006 5.032c-.381 1.626 -1.502 2.796 -2.81 2.78l-.164 -.008h-8a1 1 0 0 1 -.993 -.883l-.007 -.117l.001 -9.536a1 1 0 0 1 .5 -.865a2.998 2.998 0 0 0 1.492 -2.397l.007 -.202v-1a3 3 0 0 1 3 -3z"/>
+ <path d="M5 10a1 1 0 0 1 .993 .883l.007 .117v9a1 1 0 0 1 -.883 .993l-.117 .007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-7a2 2 0 0 1 1.85 -1.995l.15 -.005h1z"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--time">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/>
<path d="M12 7v5l3 3"/>
@@ -469,6 +520,11 @@
<path d="M12 16m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M8 11v-5a4 4 0 0 1 8 0"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--unwatch">
+ <path d="M10.585 10.587a2 2 0 0 0 2.829 2.828"/>
+ <path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"/>
+ <path d="M3 3l18 18"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--user">
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"/>
<path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/>
@@ -478,6 +534,10 @@
<path d="M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0z"/>
<path d="M12 16h.01"/>
</symbol>
+ <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--watch">
+ <path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/>
+ <path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"/>
+ </symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--wiki-page">
<path d="M6 4h11a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-11a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1m3 0v18"/>
<path d="M13 8l2 0"/>
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application-legacy.js
index 1314257ab..1219e1ef8 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application-legacy.js
@@ -43,10 +43,18 @@ function toggleRowGroup(el) {
}
function toggleExpendCollapseIcon(el) {
+ const svg = el.getElementsByTagName('svg').item(0)
+
+ if (svg === null) {
+ return false;
+ }
+
if (el.classList.contains('icon-expanded')) {
- updateSVGIcon(el, 'angle-down')
+ updateSVGIcon(svg, 'angle-down')
+ svg.classList.remove('icon-rtl')
} else {
- updateSVGIcon(el, 'angle-right')
+ updateSVGIcon(svg, 'angle-right')
+ svg.classList.add('icon-rtl')
}
}
@@ -61,13 +69,20 @@ function updateSVGIcon(element, icon) {
iconElement.setAttribute('href', iconPath.replace(/#.*$/g, "#icon--" + icon))
}
+function createSVGIcon(icon) {
+ const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true);
+ updateSVGIcon(clonedIcon, icon);
+ return clonedIcon
+}
+
function collapseAllRowGroups(el) {
var tbody = $(el).parents('tbody').first();
tbody.children('tr').each(function(index) {
if ($(this).hasClass('group')) {
$(this).removeClass('open');
- $(this).find('.expander').switchClass('icon-expanded', 'icon-collapsed');
- updateSVGIcon($(this).find('.expander')[0], 'angle-right')
+ var expander = $(this).find('.expander');
+ expander.switchClass('icon-expanded', 'icon-collapsed');
+ toggleExpendCollapseIcon(expander[0]);
} else {
$(this).hide();
}
@@ -79,8 +94,9 @@ function expandAllRowGroups(el) {
tbody.children('tr').each(function(index) {
if ($(this).hasClass('group')) {
$(this).addClass('open');
- $(this).find('.expander').switchClass('icon-collapsed', 'icon-expanded');
- updateSVGIcon($(this).find('.expander')[0], 'angle-down')
+ var expander = $(this).find('.expander');
+ expander.switchClass('icon-collapsed', 'icon-expanded');
+ toggleExpendCollapseIcon(expander[0]);
} else {
$(this).show();
}
@@ -212,8 +228,7 @@ function buildFilterRow(field, operator, values) {
case "list_status":
case "list_subprojects":
const iconType = values.length > 1 ? 'toggle-minus' : 'toggle-plus';
- const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true);
- updateSVGIcon(clonedIcon, iconType);
+ const iconSvg = createSVGIcon(iconType)
tr.find('.values').append(
$('<span>', { style: 'display:none;' }).append(
@@ -223,7 +238,7 @@ function buildFilterRow(field, operator, values) {
name: `v[${field}][]`,
}),
'\n',
- $('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(clonedIcon)
+ $('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(iconSvg)
)
);
select = tr.find('.values select');
@@ -411,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');
@@ -424,13 +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 .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;
@@ -586,19 +603,23 @@ function expandScmEntry(id) {
function scmEntryClick(id, url) {
var el = $('#'+id);
+ var expander = el.find('.expander');
+ var folder = el.find('.icon-folder');
if (el.hasClass('open')) {
collapseScmEntry(id);
el.find('.expander').switchClass('icon-expanded', 'icon-collapsed');
el.addClass('collapsed');
- updateSVGIcon(el.find('.icon-folder')[0], 'folder')
+ updateSVGIcon(folder[0], 'folder')
+ toggleExpendCollapseIcon(expander[0]);
return false;
} else if (el.hasClass('loaded')) {
expandScmEntry(id);
el.find('.expander').switchClass('icon-collapsed', 'icon-expanded');
el.removeClass('collapsed');
- updateSVGIcon(el.find('.icon-folder-open')[0], 'folder-open')
+ updateSVGIcon(folder[0], 'folder-open')
+ toggleExpendCollapseIcon(expander[0]);
return false;
}
@@ -611,8 +632,9 @@ function scmEntryClick(id, url) {
success: function(data) {
el.after(data);
el.addClass('open').addClass('loaded').removeClass('loading');
- updateSVGIcon(el.find('.icon-folder')[0], 'folder-open')
el.find('.expander').switchClass('icon-collapsed', 'icon-expanded');
+ updateSVGIcon(folder[0], 'folder-open')
+ toggleExpendCollapseIcon(expander[0]);
}
});
return true;
@@ -627,23 +649,65 @@ function randomKey(size) {
return key;
}
-function copyTextToClipboard(target) {
- if (target) {
- var temp = document.createElement('textarea');
- temp.value = target.getAttribute('data-clipboard-text');
- document.body.appendChild(temp);
- temp.select();
- document.execCommand('copy');
- if (temp.parentNode) {
- temp.parentNode.removeChild(temp);
- }
- if ($(target).closest('.drdn.expanded').length) {
- $(target).closest('.drdn.expanded').removeClass("expanded");
- }
+function copyToClipboard(text) {
+ if (navigator.clipboard) {
+ return navigator.clipboard.writeText(text).catch(() => {
+ return fallbackClipboardCopy(text);
+ });
+ } else {
+ return fallbackClipboardCopy(text);
+ }
+}
+
+function fallbackClipboardCopy(text) {
+ const temp = document.createElement('textarea');
+ temp.value = text;
+ temp.style.position = 'fixed';
+ temp.style.left = '-9999px';
+ document.body.appendChild(temp);
+ temp.select();
+ document.execCommand('copy');
+ document.body.removeChild(temp);
+ return Promise.resolve();
+}
+
+function copyDataClipboardTextToClipboard(target) {
+ copyToClipboard(target.getAttribute('data-clipboard-text'));
+
+ if ($(target).closest('.drdn.expanded').length) {
+ $(target).closest('.drdn.expanded').removeClass("expanded");
}
return false;
}
+function setupCopyButtonsToPreElements() {
+ document.querySelectorAll('.wiki pre:not(.pre-wrapper pre)').forEach((pre) => {
+ // Wrap the <pre> element with a container and add a copy button
+ const wrapper = document.createElement("div");
+ wrapper.classList.add("pre-wrapper");
+
+ const copyButton = document.createElement("a");
+ copyButton.title = rm.I18n.buttonCopy;
+ copyButton.classList.add("copy-pre-content-link", "icon-only");
+ copyButton.append(createSVGIcon("copy-pre-content"));
+
+ wrapper.appendChild(copyButton);
+ wrapper.append(pre.cloneNode(true));
+ pre.replaceWith(wrapper);
+
+ // Copy the contents of the pre tag when copyButton is clicked
+ copyButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ let textToCopy = (pre.querySelector("code") || pre).textContent.replace(/\n$/, '');
+ if (pre.querySelector("code.syntaxhl")) { textToCopy = textToCopy.replace(/ $/, ''); } // Workaround for half-width space issue in Textile's highlighted code
+ copyToClipboard(textToCopy).then(() => {
+ updateSVGIcon(copyButton, "checked");
+ setTimeout(() => updateSVGIcon(copyButton, "copy-pre-content"), 2000);
+ });
+ });
+ });
+}
+
function updateIssueFrom(url, el) {
$('#all_attributes input, #all_attributes textarea, #all_attributes select').each(function(){
$(this).data('valuebeforeupdate', $(this).val());
@@ -775,7 +839,7 @@ $(document).ready(function(){
drdn.addClass("expanded");
if ($(this).parent('#project-jump').length) {
selected = $('.drdn-items a.selected'); // Store selected project
- selected.focus(); // Calling focus to scroll to selected project
+ selected.first().focus(); // Calling focus to scroll to selected project
}
if (!isMobile()) {
drdn.find(".autocomplete").focus();
@@ -1160,8 +1224,8 @@ function setupWikiTableSortableHeader() {
});
}
-$(function () {
- $("[title]:not(.no-tooltip)").tooltip({
+function setupHoverTooltips(container) {
+ $(container || 'body').find("[title]:not(.no-tooltip)").tooltip({
show: {
delay: 400
},
@@ -1170,7 +1234,11 @@ $(function () {
at: "center top"
}
});
-});
+}
+function removeHoverTooltips(container) {
+ $(container || 'body').find("[title]:not(.no-tooltip)").tooltip('destroy')
+}
+$(function() { setupHoverTooltips(); });
function inlineAutoComplete(element) {
'use strict';
@@ -1364,3 +1432,4 @@ $(document).ready(setupWikiTableSortableHeader);
$(document).on('focus', '[data-auto-complete=true]', function(event) {
inlineAutoComplete(event.target);
});
+document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); });
diff --git a/app/assets/javascripts/attachments.js b/app/assets/javascripts/attachments.js
index df9c7090e..4c74b2d74 100644
--- a/app/assets/javascripts/attachments.js
+++ b/app/assets/javascripts/attachments.js
@@ -5,9 +5,13 @@
*/
function addFile(inputEl, file, eagerUpload) {
- var attachmentsFields = $(inputEl).closest('.attachments_form').find('.attachments_fields');
- var addAttachment = $(inputEl).closest('.attachments_form').find('.add_attachment');
+ var attachmentsForm = $(inputEl).closest('.attachments_form')
+ var attachmentsFields = attachmentsForm.find('.attachments_fields');
+ var attachmentsIcons = attachmentsForm.find('.attachments_icons');
+ var addAttachment = attachmentsForm.find('.add_attachment');
var maxFiles = ($(inputEl).attr('multiple') == 'multiple' ? 10 : 1);
+ var delIcon = attachmentsIcons.find('svg.svg-del').clone();
+ var attachmentIcon = attachmentsIcons.find('svg.svg-attachment').clone();
if (attachmentsFields.children().length < maxFiles) {
var attachmentId = addFile.nextAttachmentId++;
@@ -16,12 +20,14 @@ function addFile(inputEl, file, eagerUpload) {
if (!param) {param = 'attachments'};
fileSpan.append(
+ attachmentIcon,
$('<input>', { type: 'text', 'class': 'icon icon-attachment filename readonly', name: param +'[' + attachmentId + '][filename]', readonly: 'readonly'} ).val(file.name),
$('<input>', { type: 'text', 'class': 'description', name: param + '[' + attachmentId + '][description]', maxlength: 255, placeholder: $(inputEl).data('description-placeholder') } ).toggle(!eagerUpload),
$('<input>', { type: 'hidden', 'class': 'token', name: param + '[' + attachmentId + '][token]'} ),
- $('<a>&nbsp</a>').attr({ href: "#", 'class': 'icon-only icon-del remove-upload' }).click(removeFile).toggle(!eagerUpload)
+ $('<a>', { href: "#", 'class': 'icon-only icon-del remove-upload' }).append(delIcon).click(removeFile).toggle(!eagerUpload)
).appendTo(attachmentsFields);
+
if ($(inputEl).data('description') == 0) {
fileSpan.find('input.description').remove();
}
@@ -63,7 +69,7 @@ function ajaxUpload(file, attachmentId, fileSpan, inputEl) {
.done(function(result) {
addInlineAttachmentMarkup(file);
progressSpan.progressbar( 'value', 100 ).remove();
- fileSpan.find('input.description, a').css('display', 'inline-block');
+ fileSpan.find('input.description, a').css('display', 'inline-flex');
})
.fail(function(result) {
progressSpan.text(result.statusText);
diff --git a/app/assets/javascripts/quote_reply.js b/app/assets/javascripts/quote_reply.js
index 7649f5125..dd05d27fe 100644
--- a/app/assets/javascripts/quote_reply.js
+++ b/app/assets/javascripts/quote_reply.js
@@ -1,21 +1,6 @@
-function quoteReply(path, selectorForContentElement, textFormatting) {
- const contentElement = $(selectorForContentElement).get(0);
- const selectedRange = QuoteExtractor.extract(contentElement);
-
- let formatter;
-
- if (textFormatting === 'common_mark') {
- formatter = new QuoteCommonMarkFormatter();
- } else {
- formatter = new QuoteTextFormatter();
- }
-
- $.ajax({
- url: path,
- type: 'post',
- data: { quote: formatter.format(selectedRange) }
- });
-}
+import { Controller } from '@hotwired/stimulus'
+import TurndownService from 'turndown'
+import { post } from '@rails/request.js'
class QuoteExtractor {
static extract(targetElement) {
@@ -214,3 +199,26 @@ class QuoteCommonMarkFormatter {
return htmlFragment.innerHTML;
}
}
+
+export default class extends Controller {
+ static targets = [ 'content' ];
+
+ quote(event) {
+ const { url, textFormatting } = event.params;
+ const selectedRange = QuoteExtractor.extract(this.contentTarget);
+
+ let formatter;
+
+ if (textFormatting === 'common_mark') {
+ formatter = new QuoteCommonMarkFormatter();
+ } else {
+ formatter = new QuoteTextFormatter();
+ }
+
+ post(url, {
+ body: JSON.stringify({ quote: formatter.format(selectedRange) }),
+ contentType: 'application/json',
+ responseKind: 'script'
+ });
+ }
+}
diff --git a/app/assets/javascripts/turndown-7.2.0.min.js b/app/assets/javascripts/turndown-7.2.0.min.js
index f3fb4b1e6..e69de29bb 100644
--- a/app/assets/javascripts/turndown-7.2.0.min.js
+++ b/app/assets/javascripts/turndown-7.2.0.min.js
@@ -1,8 +0,0 @@
-/*
- * Turndown v7.2.0
- * https://github.com/mixmark-io/turndown
- * Copyright (c) 2017 Dom Christie
- * Released under the MIT license
- * https://github.com/mixmark-io/turndown/blob/master/LICENSE
- */
-var TurndownService=(()=>{function u(e,n){return Array(n+1).join(e)}var n=["ADDRESS","ARTICLE","ASIDE","AUDIO","BLOCKQUOTE","BODY","CANVAS","CENTER","DD","DIR","DIV","DL","DT","FIELDSET","FIGCAPTION","FIGURE","FOOTER","FORM","FRAMESET","H1","H2","H3","H4","H5","H6","HEADER","HGROUP","HR","HTML","ISINDEX","LI","MAIN","MENU","NAV","NOFRAMES","NOSCRIPT","OL","OUTPUT","P","PRE","SECTION","TABLE","TBODY","TD","TFOOT","TH","THEAD","TR","UL"];function f(e){return o(e,n)}var r=["AREA","BASE","BR","COL","COMMAND","EMBED","HR","IMG","INPUT","KEYGEN","LINK","META","PARAM","SOURCE","TRACK","WBR"];function d(e){return o(e,r)}var i=["A","TABLE","THEAD","TBODY","TFOOT","TH","TD","IFRAME","SCRIPT","AUDIO","VIDEO"];function o(e,n){return 0<=n.indexOf(e.nodeName)}function a(n,e){return n.getElementsByTagName&&e.some(function(e){return n.getElementsByTagName(e).length})}var t={};function c(e){return e?e.replace(/(\n+\s*)+/g,"\n"):""}function l(e){for(var n in this.options=e,this._keep=[],this._remove=[],this.blankRule={replacement:e.blankReplacement},this.keepReplacement=e.keepReplacement,this.defaultRule={replacement:e.defaultReplacement},this.array=[],e.rules)this.array.push(e.rules[n])}function s(e,n,t){for(var r=0;r<e.length;r++){var i=e[r];if(((e,n,t)=>{var r=e.filter;if("string"==typeof r)return r===n.nodeName.toLowerCase();if(Array.isArray(r))return-1<r.indexOf(n.nodeName.toLowerCase());if("function"==typeof r)return!!r.call(e,n,t);throw new TypeError("`filter` needs to be a string, array, or function")})(i,n,t))return i}}function p(e){var n=e.nextSibling||e.parentNode;return e.parentNode.removeChild(e),n}function h(e,n,t){return e&&e.parentNode===n||t(n)?n.nextSibling||n.parentNode:n.firstChild||n.nextSibling||n.parentNode}t.paragraph={filter:"p",replacement:function(e){return"\n\n"+e+"\n\n"}},t.lineBreak={filter:"br",replacement:function(e,n,t){return t.br+"\n"}},t.heading={filter:["h1","h2","h3","h4","h5","h6"],replacement:function(e,n,t){n=Number(n.nodeName.charAt(1));return"setext"===t.headingStyle&&n<3?"\n\n"+e+"\n"+u(1===n?"=":"-",e.length)+"\n\n":"\n\n"+u("#",n)+" "+e+"\n\n"}},t.blockquote={filter:"blockquote",replacement:function(e){return"\n\n"+(e=(e=e.replace(/^\n+|\n+$/g,"")).replace(/^/gm,"> "))+"\n\n"}},t.list={filter:["ul","ol"],replacement:function(e,n){var t=n.parentNode;return"LI"===t.nodeName&&t.lastElementChild===n?"\n"+e:"\n\n"+e+"\n\n"}},t.listItem={filter:"li",replacement:function(e,n,t){e=e.replace(/^\n+/,"").replace(/\n+$/,"\n").replace(/\n/gm,"\n ");var r,t=t.bulletListMarker+" ",i=n.parentNode;return"OL"===i.nodeName&&(r=i.getAttribute("start"),i=Array.prototype.indexOf.call(i.children,n),t=(r?Number(r)+i:i+1)+". "),t+e+(n.nextSibling&&!/\n$/.test(e)?"\n":"")}},t.indentedCodeBlock={filter:function(e,n){return"indented"===n.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,n,t){return"\n\n "+n.firstChild.textContent.replace(/\n/g,"\n ")+"\n\n"}},t.fencedCodeBlock={filter:function(e,n){return"fenced"===n.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,n,t){for(var r,i=((n.firstChild.getAttribute("class")||"").match(/language-(\S+)/)||[null,""])[1],o=n.firstChild.textContent,n=t.fence.charAt(0),a=3,l=new RegExp("^"+n+"{3,}","gm");r=l.exec(o);)r[0].length>=a&&(a=r[0].length+1);t=u(n,a);return"\n\n"+t+i+"\n"+o.replace(/\n$/,"")+"\n"+t+"\n\n"}},t.horizontalRule={filter:"hr",replacement:function(e,n,t){return"\n\n"+t.hr+"\n\n"}},t.inlineLink={filter:function(e,n){return"inlined"===n.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,n){var t=(t=n.getAttribute("href"))&&t.replace(/([()])/g,"\\$1"),n=c(n.getAttribute("title"));return"["+e+"]("+t+(n=n&&' "'+n.replace(/"/g,'\\"')+'"')+")"}},t.referenceLink={filter:function(e,n){return"referenced"===n.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,n,t){var r=n.getAttribute("href"),i=(i=c(n.getAttribute("title")))&&' "'+i+'"';switch(t.linkReferenceStyle){case"collapsed":a="["+e+"][]",l="["+e+"]: "+r+i;break;case"shortcut":a="["+e+"]",l="["+e+"]: "+r+i;break;default:var o=this.references.length+1,a="["+e+"]["+o+"]",l="["+o+"]: "+r+i}return this.references.push(l),a},references:[],append:function(e){var n="";return this.references.length&&(n="\n\n"+this.references.join("\n")+"\n\n",this.references=[]),n}},t.emphasis={filter:["em","i"],replacement:function(e,n,t){return e.trim()?t.emDelimiter+e+t.emDelimiter:""}},t.strong={filter:["strong","b"],replacement:function(e,n,t){return e.trim()?t.strongDelimiter+e+t.strongDelimiter:""}},t.code={filter:function(e){var n=e.previousSibling||e.nextSibling,n="PRE"===e.parentNode.nodeName&&!n;return"CODE"===e.nodeName&&!n},replacement:function(e){if(!e)return"";e=e.replace(/\r?\n|\r/g," ");for(var n=/^`|^ .*?[^ ].* $|`$/.test(e)?" ":"",t="`",r=e.match(/`+/gm)||[];-1!==r.indexOf(t);)t+="`";return t+n+e+n+t}},t.image={filter:"img",replacement:function(e,n){var t=c(n.getAttribute("alt")),r=n.getAttribute("src")||"",n=c(n.getAttribute("title"));return r?"!["+t+"]("+r+(n?' "'+n+'"':"")+")":""}},l.prototype={add:function(e,n){this.array.unshift(n)},keep:function(e){this._keep.unshift({filter:e,replacement:this.keepReplacement})},remove:function(e){this._remove.unshift({filter:e,replacement:function(){return""}})},forNode:function(e){return e.isBlank?this.blankRule:s(this.array,e,this.options)||s(this._keep,e,this.options)||s(this._remove,e,this.options)||this.defaultRule},forEach:function(e){for(var n=0;n<this.array.length;n++)e(this.array[n],n)}};var g,m="undefined"!=typeof window?window:{},A=(()=>{var e=m.DOMParser,n=!1;try{(new e).parseFromString("","text/html")&&(n=!0)}catch(e){}return n})()?m.DOMParser:((()=>{var n=!1;try{document.implementation.createHTMLDocument("").open()}catch(e){m.ActiveXObject&&(n=!0)}return n})()?e.prototype.parseFromString=function(e){var n=new window.ActiveXObject("htmlfile");return n.designMode="on",n.open(),n.write(e),n.close(),n}:e.prototype.parseFromString=function(e){var n=document.implementation.createHTMLDocument("");return n.open(),n.write(e),n.close(),n},e);function e(){}function y(e,n){var n={element:e="string"==typeof e?(g=g||new A).parseFromString('<x-turndown id="turndown-root">'+e+"</x-turndown>","text/html").getElementById("turndown-root"):e.cloneNode(!0),isBlock:f,isVoid:d,isPre:n.preformattedCode?v:null},t=n.element,r=n.isBlock,i=n.isVoid,o=n.isPre||function(e){return"PRE"===e.nodeName};if(t.firstChild&&!o(t)){for(var a=null,l=!1,u=h(s=null,t,o);u!==t;){if(3===u.nodeType||4===u.nodeType){var c=u.data.replace(/[ \r\n\t]+/g," ");if(!(c=a&&!/ $/.test(a.data)||l||" "!==c[0]?c:c.substr(1))){u=p(u);continue}u.data=c,a=u}else{if(1!==u.nodeType){u=p(u);continue}r(u)||"BR"===u.nodeName?(a&&(a.data=a.data.replace(/ $/,"")),a=null,l=!1):i(u)||o(u)?l=!(a=null):a&&(l=!1)}var c=h(s,u,o),s=u,u=c}a&&(a.data=a.data.replace(/ $/,""),a.data||p(a))}return e}function v(e){return"PRE"===e.nodeName||"CODE"===e.nodeName}function N(e,n){var t;return e.isBlock=f(e),e.isCode="CODE"===e.nodeName||e.parentNode.isCode,e.isBlank=!d(t=e)&&!(e=>o(e,i))(t)&&/^\s*$/i.test(t.textContent)&&!(e=>a(e,r))(t)&&!(e=>a(e,i))(t),e.flankingWhitespace=((e,n)=>{var t;return e.isBlock||n.preformattedCode&&e.isCode?{leading:"",trailing:""}:((t=(e=>({leading:(e=e.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/))[1],leadingAscii:e[2],leadingNonAscii:e[3],trailing:e[4],trailingNonAscii:e[5],trailingAscii:e[6]}))(e.textContent)).leadingAscii&&E("left",e,n)&&(t.leading=t.leadingNonAscii),t.trailingAscii&&E("right",e,n)&&(t.trailing=t.trailingNonAscii),{leading:t.leading,trailing:t.trailing})})(e,n),e}function E(e,n,t){var r,i,e="left"===e?(r=n.previousSibling,/ $/):(r=n.nextSibling,/^ /);return r&&(3===r.nodeType?i=e.test(r.nodeValue):t.preformattedCode&&"CODE"===r.nodeName?i=!1:1!==r.nodeType||f(r)||(i=e.test(r.textContent))),i}var T=Array.prototype.reduce,R=[[/\\/g,"\\\\"],[/\*/g,"\\*"],[/^-/g,"\\-"],[/^\+ /g,"\\+ "],[/^(=+)/g,"\\$1"],[/^(#{1,6}) /g,"\\$1 "],[/`/g,"\\`"],[/^~~~/g,"\\~~~"],[/\[/g,"\\["],[/\]/g,"\\]"],[/^>/g,"\\>"],[/_/g,"\\_"],[/^(\d+)\. /g,"$1\\. "]];function C(e){if(!(this instanceof C))return new C(e);this.options=function(e){for(var n=1;n<arguments.length;n++){var t,r=arguments[n];for(t in r)r.hasOwnProperty(t)&&(e[t]=r[t])}return e}({},{rules:t,headingStyle:"setext",hr:"* * *",bulletListMarker:"*",codeBlockStyle:"indented",fence:"```",emDelimiter:"_",strongDelimiter:"**",linkStyle:"inlined",linkReferenceStyle:"full",br:" ",preformattedCode:!1,blankReplacement:function(e,n){return n.isBlock?"\n\n":""},keepReplacement:function(e,n){return n.isBlock?"\n\n"+n.outerHTML+"\n\n":n.outerHTML},defaultReplacement:function(e,n){return n.isBlock?"\n\n"+e+"\n\n":e}},e),this.rules=new l(this.options)}function k(e){var r=this;return T.call(e.childNodes,function(e,n){var t="";return 3===(n=new N(n,r.options)).nodeType?t=n.isCode?n.nodeValue:r.escape(n.nodeValue):1===n.nodeType&&(t=function(e){var n=this.rules.forNode(e),t=k.call(this,e),r=e.flankingWhitespace;(r.leading||r.trailing)&&(t=t.trim());return r.leading+n.replacement(t,e,this.options)+r.trailing}.call(r,n)),b(e,t)},"")}function b(e,n){var t=(e=>{for(var n=e.length;0<n&&"\n"===e[n-1];)n--;return e.substring(0,n)})(e),r=n.replace(/^\n*/,""),e=Math.max(e.length-t.length,n.length-r.length);return t+"\n\n".substring(0,e)+r}return C.prototype={turndown:function(e){if(null==(n=e)||"string"!=typeof n&&(!n.nodeType||1!==n.nodeType&&9!==n.nodeType&&11!==n.nodeType))throw new TypeError(e+" is not a string, or an element/document/fragment node.");var n;return""===e?"":(n=k.call(this,new y(e,this.options)),function(n){var t=this;return this.rules.forEach(function(e){"function"==typeof e.append&&(n=b(n,e.append(t.options)))}),n.replace(/^[\t\r\n]+/,"").replace(/[\t\r\n\s]+$/,"")}.call(this,n))},use:function(e){if(Array.isArray(e))for(var n=0;n<e.length;n++)this.use(e[n]);else{if("function"!=typeof e)throw new TypeError("plugin must be a Function or an Array of Functions");e(this)}return this},addRule:function(e,n){return this.rules.add(e,n),this},keep:function(e){return this.rules.keep(e),this},remove:function(e){return this.rules.remove(e),this},escape:function(e){return R.reduce(function(e,n){return e.replace(n[0],n[1])},e)}},C})();
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index a17be1e81..e994c12af 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -236,11 +236,14 @@ a.user.user-mention {
#sidebar a.selected {line-height:1.7em; padding:1px 3px 2px 2px; margin-left:-2px; background-color:#9DB9D5; color:#fff; border-radius:2px;}
#sidebar a.selected:hover {text-decoration:none;}
+#sidebar a.selected svg.icon-svg { stroke: #fff !important; }
#sidebar .query.default {font-weight: bold;}
#admin-menu a {line-height:1.7em;}
#admin-menu a.selected:not(:has(svg)) {padding-left: 20px !important; background-position: 2px 40%;}
a#toggle-completed-versions {color:#999;}
+a#toggle-completed-versions span.icon-label {margin-left: 0}
+a#toggle-completed-versions svg, a#toggle-completed-versions:hover svg {stroke:#999}
/***** Dropdown *****/
.drdn {position:relative;}
@@ -303,24 +306,44 @@ 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 {
width:100%;
height:24px;
display:inline-block;
- padding:3px 18px 3px 6px;
+ padding:1.5px 18px 3px 6px;
border-radius:3px;
border:1px solid #ccc;
margin:0 !important;
vertical-align:middle;
color:#555;
- background:#fff url(/arrow_down.png) no-repeat 97% 50%;
+ background:#fff url(/chevron-down.svg) no-repeat 98% 50%;
}
#project-jump .drdn.expanded .drdn-trigger {background-image:url(/arrow_up.png);}
#project-jump .drdn-content {width:280px;}
@@ -356,10 +379,14 @@ table.list td.buttons a, div.buttons a, table.list td.buttons span.icon-only { m
table.list td.buttons a:last-child, div.buttons a:last-child { margin-right: 0; }
table.list td.buttons img, div.buttons img {vertical-align:middle;}
table.list td.reorder {width:15%; white-space:nowrap; text-align:center; }
-table.list table.progress td {padding-right:0px;}
+table.list table.progress td {padding-right:0; border-top: none;}
table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
table.list tr.overdue td.due_date { color: #c22; }
+table.list thead.related-issues th { background-color: inherit; font-size: 11px; border: none; }
#role-permissions-trackers table.list th {white-space:normal;}
+table.list div.wiki p {
+ margin: 0;
+}
.table-list-cell {display: table-cell; vertical-align: top; padding:2px; }
.table-list div.buttons {width: 15%;}
@@ -381,16 +408,16 @@ table.issues td.block_column {color:#777; font-size:90%; padding:4px 4px 4px 24p
table.issues td.block_column>span {font-weight: bold; display: block; margin-bottom: 4px;}
table.issues td.block_column>pre {white-space:normal;}
-tr.idnt td.subject, tr.idnt td.name {background: url(/arrow_right.png) no-repeat 2px 50%;}
-tr.idnt-1 td.subject, tr.idnt-1 td.name {padding-left: 24px; background-position: 8px 50%;}
-tr.idnt-2 td.subject, tr.idnt-2 td.name {padding-left: 40px; background-position: 24px 50%;}
-tr.idnt-3 td.subject, tr.idnt-3 td.name {padding-left: 56px; background-position: 40px 50%;}
-tr.idnt-4 td.subject, tr.idnt-4 td.name {padding-left: 72px; background-position: 56px 50%;}
-tr.idnt-5 td.subject, tr.idnt-5 td.name {padding-left: 88px; background-position: 72px 50%;}
-tr.idnt-6 td.subject, tr.idnt-6 td.name {padding-left: 104px; background-position: 88px 50%;}
-tr.idnt-7 td.subject, tr.idnt-7 td.name {padding-left: 120px; background-position: 104px 50%;}
-tr.idnt-8 td.subject, tr.idnt-8 td.name {padding-left: 136px; background-position: 120px 50%;}
-tr.idnt-9 td.subject, tr.idnt-9 td.name {padding-left: 152px; background-position: 136px 50%;}
+tr.idnt td.subject, tr.idnt td.name {background: url(/chevron-right-idnt.svg) no-repeat 2px 50%;}
+tr.idnt-1 td.subject, tr.idnt-1 td.name {padding-left: 24px; background-position: 4px 50%;}
+tr.idnt-2 td.subject, tr.idnt-2 td.name {padding-left: 40px; background-position: 20px 50%;}
+tr.idnt-3 td.subject, tr.idnt-3 td.name {padding-left: 56px; background-position: 36px 50%;}
+tr.idnt-4 td.subject, tr.idnt-4 td.name {padding-left: 72px; background-position: 52px 50%;}
+tr.idnt-5 td.subject, tr.idnt-5 td.name {padding-left: 88px; background-position: 68px 50%;}
+tr.idnt-6 td.subject, tr.idnt-6 td.name {padding-left: 104px; background-position: 84px 50%;}
+tr.idnt-7 td.subject, tr.idnt-7 td.name {padding-left: 120px; background-position: 100px 50%;}
+tr.idnt-8 td.subject, tr.idnt-8 td.name {padding-left: 136px; background-position: 116px 50%;}
+tr.idnt-9 td.subject, tr.idnt-9 td.name {padding-left: 152px; background-position: 132px 50%;}
table.issue-report {table-layout:fixed;}
table.issue-report tr.total, table.issue-report-detailed tr.total { font-weight: bold; border-top:2px solid #d0d7de;}
@@ -429,15 +456,11 @@ 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; }
-#principals_for_new_member .icon-user {background:transparent;}
+#principals_for_new_member .icon-user, #users_for_watcher .icon-user {background:transparent;}
#principals_for_new_member svg, #principals_for_new_member img {margin-right: 4px;}
tr.user td {width:13%;white-space: nowrap;}
@@ -586,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 { margin-top: 0; }
.splitcontent {overflow: auto; display: flex; flex-wrap: wrap;}
.splitcontentleft {flex: 1; margin-right: 5px;}
@@ -602,9 +624,9 @@ select {
-o-appearance: none;
appearance: none;
background-color: #fff;
- background-image: url(/arrow_down.png);
+ background-image: url(/chevron-down.svg);
background-repeat: no-repeat;
- background-position: calc(100% - 7px) 50%;
+ background-position: calc(100% - 2px) 50%;
padding-right: 20px;
}
input[type="file"] {border: 0; padding-left: 0; padding-right: 0; height: initial; background-color: initial; }
@@ -676,6 +698,36 @@ div.issue .attribute.string_cf .value .wiki p {margin-top: 0; margin-bottom: 0;}
div.issue .attribute.text_cf .value .wiki p:first-of-type {margin-top: 0;}
div.issue.overdue .due-date .value { color: #c22; }
body.controller-issues h2.inline-flex {padding-right: 0}
+div#sticky-issue-header {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ background-color: white;
+ border-bottom: 1px solid #d0d7de;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+ font-size: 0.8125rem;
+ align-items: center;
+ z-index: 1000;
+ padding: 10px 6px;
+ border-radius: 0px;
+}
+div#sticky-issue-header.is-visible {
+ display: flex;
+}
+div#sticky-issue-header .issue-heading {
+ flex-shrink: 0;
+ white-space: nowrap;
+ margin-right: 6px;
+}
+div#sticky-issue-header .subject {
+ font-weight: bold;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex-grow: 1;
+}
#issue_tree table.issues, #relations table.issues {border: 0;}
#issue_tree table.issues td, #relations table.issues td {border: 0;}
@@ -738,29 +790,15 @@ 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],
+.controller-issues.action-show #history div[id^="note-"],
+.controller-issues.action-show #history div[id^="change-"] {
+ scroll-margin-top: 50px;
+}
div#activity dl, #search-results { margin-left: 2em; }
div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 22px; font-size: 0.8125rem;}
@@ -882,7 +920,11 @@ ul.projects div.description ul li {list-style-type:initial;}
background-image: none;
padding-left: 0;
}
-#projects-index ul.projects div.root svg {
+#projects-index ul.projects .icon-bookmarked-project svg,
+#projects-index ul.projects .my-project svg {
+ margin-left: 4px;
+}
+#projects-index ul.projects div.root .icon-bookmarked-project svg, #projects-index ul.projects div.root .my-project svg {
stroke-width: 2;
margin-bottom: 10px;
}
@@ -893,7 +935,12 @@ ul.projects div.description ul li {list-style-type:initial;}
background-image: none;
padding-left: 0;
}
-#projects-index a.project ~ svg, table.projects tr.project td.name svg {
+#projects-index div.wiki p {
+ margin-top: 0px;
+}
+
+table.projects td.name .icon-bookmarked-project svg,
+table.projects td.name .my-project svg {
margin-left: 4px;
}
@@ -1057,17 +1104,14 @@ input#months { width: 46px; }
.jstBlock .jstTabs { padding-right: 6px; }
.jstBlock .wiki-preview { padding: 2px; }
-.jstBlock .wiki-preview p:first-child { padding-top: 0 !important; margin-top: 0 !important;}
-.jstBlock .wiki-preview p:last-child { padding-bottom: 0 !important; margin-bottom: 0 !important;}
+.jstBlock .wiki-preview > p:first-child { padding-top: 0 !important; margin-top: 0 !important;}
+.jstBlock .wiki-preview > p:last-child { padding-bottom: 0 !important; margin-bottom: 0 !important;}
.tabular .wiki-preview, .tabular .jstTabs {width: 95%;}
.tabular.settings .wiki-preview, .tabular.settings .jstTabs { width: 99%; }
.tabular.settings .wiki-preview p {padding-left: 0 !important}
.tabular .wiki-preview p {
min-height: initial;
- padding: 0;
- padding-top: 1em !important;
- padding-bottom: 1em !important;
overflow: initial;
}
@@ -1108,13 +1152,29 @@ span.required {color: #bb0000;}
.attachments_fields input.description, #existing-attachments input.description {margin-left:4px; width:340px;}
.attachments_fields>span, #existing-attachments>span {display:block; white-space:nowrap;}
/* ToDo: delete this line when legacy icons are deleted */
-.attachments_fields , #existing-attachments .icon-attachment {background-image: none; padding-left: 0}
+.attachments_fields .icon-attachment, #existing-attachments .icon-attachment {background-image: none; padding-left: 0}
.attachments_fields input.filename, #existing-attachments .filename {border:0; width:250px; color:#555; background-color:inherit; }
.tabular input.filename {max-width:75% !important;}
-.attachments_fields input.filename {height:1.8em;}
-.attachments_fields .ajax-waiting input.filename {background:url(/hourglass.png) no-repeat 0px 50%;}
-.attachments_fields .ajax-loading input.filename {background:url(/loading.gif) no-repeat 0px 50%;}
.attachments_fields div.ui-progressbar { width: 100px; height:14px; margin: 2px 0 -5px 8px; display: inline-block; }
+.attachments_fields input.filename {
+ height:1.8em;
+ padding-left: 3px;
+ padding-right: 0;
+}
+.attachments_fields .ajax-waiting {
+ padding-left: 16px;
+ background:url(/hourglass-empty.svg) no-repeat 0px 50%;
+}
+.attachments_fields .ajax-waiting .svg-attachment {
+ display: none;
+}
+.attachments_fields .ajax-loading {
+ padding-left: 16px;
+ background: url(/loading.gif) no-repeat 0px 50%;
+}
+.attachments_fields .ajax-loading .svg-attachment {
+ display: none;
+}
a.remove-upload:hover {text-decoration:none !important;}
.existing-attachment.deleted .filename {text-decoration:line-through; color:#999 !important;}
@@ -1128,13 +1188,10 @@ 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: "| "; }
-a.atom { background: url(/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
-
em.info {font-style:normal;display:block;font-size:90%;color:#888;}
em.info.error {padding-left:20px; background:url(/exclamation.png) no-repeat 0 50%;}
@@ -1192,28 +1249,59 @@ input.autocomplete.ajax-loading {
}
div.flash {margin-top: 8px;}
+div.flash svg.icon-svg, #errorExplanation svg.icon-svg, .conflict svg.icon-svg {
+ margin-right: 4px;
+ margin-left: -26px;
+}
div.flash.error, #errorExplanation {
- background: url(/exclamation.png) 8px 50% no-repeat;
background-color: #ffe3e3;
border-color: #d88;
color: #880000;
}
+div.flash.error:not(:has(svg)), #errorExplanation:not(:has(svg)) {
+ background: url(/exclamation.png) 8px 50% no-repeat #ffe3e3;
+}
+div.flash.error svg.icon-svg, #errorExplanation svg.icon-svg {
+ stroke: #880000;
+}
+
+#errorExplanation:has(svg) {
+ position: relative;
+}
+
+#errorExplanation:has(svg) > svg.icon-svg {
+ position: absolute;
+ top: 50%;
+ bottom: 50%;
+ margin-left: -24px;
+ margin-top: -9px;
+}
div.flash.notice {
- background: url(/true.png) 8px 5px no-repeat;
background-color: #dfffdf;
border-color: #9fcf9f;
color: #005f00;
}
+div.flash.notice:not(:has(svg)) {
+ background: url(/true.png) 8px 50% no-repeat #dfffdf;
+}
+div.flash.notice svg.icon-svg {
+ stroke: #005f00;
+}
div.flash.warning, .conflict {
- background: url(/warning.png) 8px 5px no-repeat;
background-color: #F3EDD1;
border-color: #eadbbc;
color: #A6750C;
text-align: left;
}
+div.flash.warning:not(:has(svg)), .conflict:not(:has(svg)) {
+ background: url(/warning.png) 8px 5px no-repeat #F3EDD1;
+}
+div.flash.warning svg.icon-svg, .conflict svg.icon-svg {
+ stroke: #A6750C;
+}
.nodata, .warning {
text-align: center;
@@ -1222,11 +1310,45 @@ div.flash.warning, .conflict {
color: #A6750C;
}
+.warning .oauth-permissions { display:inline-block;text-align:left; }
+.warning .oauth-permissions p { margin-top:0;-webkit-margin-before:0;}
+
#errorExplanation ul { font-size: 0.9em;}
#errorExplanation h2, #errorExplanation p { display: none; }
.conflict-details {font-size:93%;}
+/***** CommonMark Alerts *****/
+.markdown-alert {
+ border-left: 4px solid;
+ padding-left: 0.6em;
+ margin: 1em 0;
+}
+
+.markdown-alert-title {
+ font-weight: bold;
+}
+
+.markdown-alert-tip { border-color: #5db651; }
+.markdown-alert-tip .markdown-alert-title { color: #005f00; }
+.markdown-alert-tip .markdown-alert-title svg {stroke: #005f00; }
+
+.markdown-alert-important { border-color: #800080; }
+.markdown-alert-important .markdown-alert-title { color: #4b006e; }
+.markdown-alert-important .markdown-alert-title svg { stroke: #4b006e; }
+
+.markdown-alert-caution { border-color: #c22; }
+.markdown-alert-caution .markdown-alert-title { color: #880000; }
+.markdown-alert-caution .markdown-alert-title svg { stroke: #880000; }
+
+.markdown-alert-warning { border-color: #e4bc4b; }
+.markdown-alert-warning .markdown-alert-title { color: #a7760c; }
+.markdown-alert-warning .markdown-alert-title svg { stroke: #a7760c; }
+
+.markdown-alert-note { border-color: #169; }
+.markdown-alert-note .markdown-alert-title { color: #1e40af; }
+.markdown-alert-note .markdown-alert-title svg { stroke: #1e40af; }
+
/***** Ajax indicator ******/
#ajax-indicator {
position: absolute; /* fixed not supported by IE */
@@ -1431,18 +1553,26 @@ button.tab-left:hover, button.tab-right:hover {
button.tab-left:focus, button.tab-right:focus {
outline: 0;
}
+button.tab-left svg.icon-svg, button.tab-right svg.icon-svg {
+ stroke: #999;
+ stroke-width: 2;
+}
button.tab-left {
right: 28px;
- background: #eeeeee url(/arrow_left.png) no-repeat 50% 50%;
border-top-left-radius:3px;
}
+button.tab-left:not(:has(svg)) {
+ background: #eeeeee url(/arrow_left.png) no-repeat 50% 50%;
+}
button.tab-right {
right: 4px;
- background: #eeeeee url(/arrow_right.png) no-repeat 50% 50%;
border-top-right-radius:3px;
}
+button.tab-right:not(:has(svg)) {
+ background: #eeeeee url(/arrow_right.png) no-repeat 50% 50%;
+}
button.tab-left.disabled, button.tab-right.disabled {
background-color: #ccc;
@@ -1493,11 +1623,20 @@ div.wiki .external {
div.wiki a {word-wrap: break-word;}
div.wiki a.new {color: #b73535;}
-div.wiki p {line-height: 1.6;}
+div.wiki p {
+ line-height: 1.6;
+ margin-top: 1em;
+ margin-bottom: 1em;
+ padding: 0;
+}
div.wiki ul, div.wiki ol {margin-bottom:1em;}
div.wiki li {line-height: 1.6; margin-bottom: 0.125rem;}
div.wiki li>ul, div.wiki li>ol {margin-bottom: 0;}
+div.wiki div.pre-wrapper {
+ position: relative;
+}
+
div.wiki pre {
margin: 1em 1em 1em 1.6em;
padding: 8px;
@@ -1515,6 +1654,22 @@ div.wiki *:not(pre)>code, div.wiki>code {
border-radius: 0.1em;
}
+div.pre-wrapper a.copy-pre-content-link {
+ position: absolute;
+ top: 3px;
+ right: calc(1em + 3px);
+ cursor: pointer;
+ display: none;
+ border-radius: 3px;
+ background: #fff;
+ border: 1px solid #ccc;
+ padding: 0px 3px 3px 3px;
+}
+
+div.pre-wrapper:hover a.copy-pre-content-link {
+ display: block;
+}
+
div.wiki ul.toc {
background-color: #ffffdd;
border: 1px solid #e4e4e4;
@@ -1548,10 +1703,11 @@ a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor, h4:hover a.wiki-anchor, h5:hover a.wiki-anchor, h6:hover a.wiki-anchor { display: inline; color: #ddd; }
div.wiki img {vertical-align:middle; max-width:100%;}
-div.wiki>.task-list {
- padding-left: 0px;
+
+div.wiki>.contains-task-list {
+ padding-left: 0;
}
-div.wiki .task-list {
+div.wiki .contains-task-list {
list-style-type: none;
}
div.wiki .task-list input.task-list-item-checkbox {
@@ -1583,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%;
@@ -1636,7 +1793,7 @@ table.gantt-table td {
width: 100%;
}
.gantt_subjects div.issue-subject:hover { background-color:#ffffdd; }
-.gantt_selected_column_content { padding-left: 3px; padding-right: 3px;}
+.gantt_selected_column_content > div { padding-left: 3px; box-sizing: border-box; }
.gantt_subjects .issue-subject img.icon-gravatar {
margin: 2px 5px 0px 2px;
}
@@ -1660,6 +1817,21 @@ td.gantt_selected_column .gantt_hdr,.gantt_selected_column_container {
width: 49px;
}
+td.gantt_watcher_users_column div.issue_watcher_users ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+td.gantt_watcher_users_column div.issue_watcher_users ul li {
+ display: inline;
+}
+
+td.gantt_watcher_users_column div.issue_watcher_users ul li:not(:last-child)::after {
+ content: ', ';
+ white-space: pre;
+}
+
.task {
position: absolute;
height:8px;
@@ -1696,6 +1868,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;
@@ -1757,10 +1991,15 @@ td.gantt_selected_column .gantt_hdr,.gantt_selected_column_container {
flex-shrink: 0;
}
-a.icon:hover svg, a.icon-only:hover svg {
+a.icon:hover .icon-svg, a.icon-only:hover .icon-svg, span.icon-actions:hover .icon-svg {
stroke: #c61a1a;
}
+a.icon:hover .icon-svg-filled, a.icon-only:hover .icon-svg-filled {
+ stroke: none;
+ fill: #c61a1a;
+}
+
svg.icon-ok {
stroke: #5db651;
}
@@ -1777,11 +2016,6 @@ svg.icon-ok {
display: none;
}
-.icon-fav svg.icon-svg {
- fill: #ffc400;
- stroke: #ffc400;
-}
-
svg.icon-svg {
stroke: #169;
fill: none;
@@ -1789,6 +2023,11 @@ svg.icon-svg {
vertical-align: middle;
}
+svg.icon-svg-filled {
+ fill: #169;
+ stroke: none;
+}
+
svg.s20 {
width: 1.25rem;
height: 1.25rem;
@@ -1799,6 +2038,11 @@ svg.s18 {
height: 1.125rem;
}
+svg.s16 {
+ width: 1rem;
+ height: 1rem;
+}
+
svg.s14 {
width: 0.875rem;
height: 0.875rem;
@@ -1963,7 +2207,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 */
@@ -1990,6 +2233,42 @@ color: #555; text-shadow: 1px 1px 0 #fff;
img.filecontent.image {background-image: url(/transparent.png);}
+/* Reaction styles */
+.reaction-button:hover, .reaction-button:active {
+ text-decoration: none;
+}
+.reaction-button .icon-label {
+ margin-left: 3px;
+ margin-bottom: -1px;
+ font-weight: bold;
+}
+.reaction-button.readonly {
+ cursor: default;
+}
+.reaction-button.readonly .icon-svg {
+ stroke: #999;
+}
+.reaction-button.readonly .icon-label {
+ color: #999;
+}
+div.issue.details .reaction {
+ float: right;
+ font-size: 0.9em;
+ margin-top: 0.5em;
+ margin-left: 10px;
+ clear: right;
+}
+div.message .reaction {
+ float: right;
+ font-size: 0.9em;
+ margin-left: 10px;
+}
+div.news .reaction {
+ float: right;
+ font-size: 0.9em;
+ margin-left: 10px;
+}
+
/* Custom JQuery styles */
.ui-autocomplete, .ui-menu {
border-radius: 2px;
diff --git a/app/assets/stylesheets/context_menu.css b/app/assets/stylesheets/context_menu.css
index 875564c8f..637b89a32 100644
--- a/app/assets/stylesheets/context_menu.css
+++ b/app/assets/stylesheets/context_menu.css
@@ -45,7 +45,8 @@
}
#context-menu li>a { flex-grow: 1; }
#context-menu a.disabled, #context-menu a.disabled:hover {color: #aaa;}
-#context-menu li a.submenu { padding-right:16px; background:url("/arrow_right.png") right no-repeat; padding-left: 28px;}
+#context-menu li a.submenu:not(:has(+ span)) { padding-right:16px; background:url("/arrow_right.png") right no-repeat;}
+#context-menu li a.submenu { padding-left: 28px;}
#context-menu li:hover { border:1px solid #628db6; background-color:#eef5fd; border-radius:3px; }
#context-menu a:hover {color:#2A5685;}
#context-menu li.folder ul li a:not(.icon) {
@@ -59,6 +60,7 @@
.context-menu-selection { background-color:#507AAA !important; color:#f8f8f8 !important; }
.context-menu-selection a, .context-menu-selection a:hover { color:#f8f8f8 !important; }
.context-menu-selection:hover { background-color:#507AAA !important; color:#f8f8f8 !important; }
+.context-menu-selection svg.icon-svg { stroke: #fff !important; }
div#gantt_area .context-menu-selection { background-color: rgba(80, 122, 170, 0.48) !important; }
div#gantt_area .context-menu-selection:hover { background-color: rgba(80, 122, 170, 0.48) !important; }
div#gantt_area .context-menu-selection a { color: #169 !important; }
diff --git a/app/assets/stylesheets/context_menu_rtl.css b/app/assets/stylesheets/context_menu_rtl.css
index cab05ad64..b373ad146 100644
--- a/app/assets/stylesheets/context_menu_rtl.css
+++ b/app/assets/stylesheets/context_menu_rtl.css
@@ -6,10 +6,9 @@
#context-menu li.folder ul { left:auto; right:168px; }
#context-menu li.folder>ul { left:auto; right:148px; }
-#context-menu li a.submenu { background:url("/arrow_left.png") left no-repeat; }
-
-#context-menu a {
- background-position: 100% 40%;
- padding-right: 20px;
- padding-left: 0px;
+#context-menu li a.submenu:not(:has(+ span)) { background:url("/arrow_left.png") left no-repeat; }
+#context-menu li.folder ul li a:not(.icon) {padding-right: 28px;}
+#context-menu li a.submenu {
+ padding-right: 28px;
+ padding-left: 0;
}
diff --git a/app/assets/stylesheets/responsive.css b/app/assets/stylesheets/responsive.css
index ec580037c..b3e8bddd8 100644
--- a/app/assets/stylesheets/responsive.css
+++ b/app/assets/stylesheets/responsive.css
@@ -385,7 +385,7 @@
list-style: none;
}
- .flyout-menu #watchers {
+ .flyout-menu #watchers, .flyout-menu .queries {
display: -webkit-flex;
display: -webkit-box;
display: flex;
@@ -402,11 +402,11 @@
order: 3;
}
- .flyout-menu #watchers h3 {
+ #sidebar-wrapper {
margin-left: -8px;
}
- .flyout-menu #watchers ul li {
+ .flyout-menu #watchers ul li, .flyout-menu ul.queries li {
display: -webkit-flex;
display: -webkit-box;
display: flex;
@@ -418,6 +418,16 @@
-webkit-align-items: center;
-webkit-box-align: center;
align-items: center;
+ border-top: 1px solid rgba(255,255,255,.1);
+ }
+
+ .flyout-menu #watchers ul li a, .flyout-menu ul.queries li a {
+ border-top: none;
+ }
+
+ .flyout-menu ul.queries li a.icon-clear-query {
+ flex-shrink: 0;
+ padding-right: 8px;
}
.flyout-menu ul li a {
@@ -440,7 +450,7 @@
color: white;
}
- .flyout-menu .icon svg {
+ .flyout-menu .icon svg, .flyout-menu .icon-only svg {
stroke: white;
}
@@ -771,6 +781,10 @@
width: 100%; /* let subject have one full width column */
}
+ #issue_tree .issue:has(.buttons a) > td.subject, #relations .issue:has(.buttons a) > td.subject {
+ padding-right: 40px;
+ }
+
#issue_tree .issue > td:not(.subject), #relations .issue > td:not(.subject) {
width: 20%; /* three columns for all cells that are not subject */
}
@@ -844,6 +858,19 @@
font-size: 1.1em;
text-align: left;
}
+
+ /* Sticky issue header */
+ /* When project-jump.drdn is visible in mobile layout, offset the sticky header by its height to prevent it from being hidden. */
+ div#sticky-issue-header {
+ top: 64px;
+ }
+
+ /* Prevent content from being hidden behind #sticky-issue-header and project-jump when scrolling via anchor links. */
+ .controller-issues.action-show div.wiki a[name],
+ .controller-issues.action-show #history div[id^="note-"],
+ .controller-issues.action-show #history div[id^="change-"] {
+ scroll-margin-top: 114px;
+ }
}
@media all and (max-width: 599px) {
diff --git a/app/assets/stylesheets/rtl.css b/app/assets/stylesheets/rtl.css
index 0e7096d8a..20a2a73dc 100644
--- a/app/assets/stylesheets/rtl.css
+++ b/app/assets/stylesheets/rtl.css
@@ -21,12 +21,29 @@ h1, h2, h3, h4 {padding:2px 00px 1px 10px;}
#main-menu {left:auto;right:6px;margin-right:0;margin-left:-500px;}
#main-menu li {float:right;margin:0px 0px 0px 2px;}
-#admin-menu a {padding-left:0;padding-right:20px;}
+#admin-menu a:not(:has(svg)) {padding-left:0;padding-right:20px;}
+
+#sidebar {float:left; padding-right: 20px; padding-left: 8px; border-left: 0; border-right: 1px solid #d0d7de;}
+* html #sidebar hr {left: auto; right: -6px;}
+
+#main.collapsedsidebar #sidebar {
+ padding-left: 0;
+ padding-right: 20px;
+}
-#sidebar {float:left;}
-* html #sidebar hr{ left: auto; right: -6px; }
#sidebar .contextual { margin-right: 0; margin-left: 1em;}
-#sidebar ul li {margin: 0px 0px 0px 2px;}
+#sidebar ul li {margin: 0 0 0 2px;}
+#sidebar #sidebar-switch-panel {
+ margin-left: 0;
+ margin-right: -20px;
+ padding-left: 28px;
+ padding-right: 0;
+}
+
+#sidebar #sidebar-switch-panel #sidebar-switch-button {
+ padding-right: 0;
+ padding-left: 28px;
+}
#content {border-right:0 solid #ddd; border-left:1px solid #ddd;}
* html #content{padding-right:0;}
@@ -38,10 +55,10 @@ div.modal p.buttons {text-align:left;}
/***** Links *****/
#sidebar a.selected {padding:1px 2px 2px 3px; margin-left:0px; margin-right:-2px;}
-#admin-menu a.selected {padding-left:0!important; padding-right:20px!important; background-position:right 2px 40%;}
+#admin-menu a.selected:not(:has(svg)) {padding-left:0!important; padding-right:20px!important; background-position:right 2px 40%;}
-a.collapsible {padding-left:0px; padding-right:12px; background: url(/arrow_down.png) no-repeat right 0px top 50%;}
-a.collapsible.collapsed {background-image: url(/arrow_left.png);}
+a.collapsible:not(:has(svg)) {padding-left:0px; padding-right:12px; background: url(/arrow_down.png) no-repeat right 0px top 50%;}
+a.collapsible.collapsed:not(:has(svg)) {background-image: url(/arrow_left.png);}
/***** Tables *****/
table.list td {padding-left:0px; padding-right:10px;}
@@ -122,7 +139,7 @@ div.projects h3 {padding-left:0px; padding-right:20px;}
#watchers li {margin: 0px 0px 0px 2px; padding: 0px 0px 0px 0px;}
#watchers img.gravatar {margin: 0 0 2px 4px;}
-span.search_for_watchers a, span.add_attachment a {padding-left:px; padding-right:16px; background: url(/bullet_add.png) no-repeat right 50%; }
+span.search_for_watchers a:not(:has(svg)), span.add_attachment a:not(:has(svg)) {padding-left:0px; padding-right:16px; background: url(/bullet_add.png) no-repeat right 50%; }
div.square {float:right;}
.contextual {float:left; padding-left:0px; padding-right:10px;}
@@ -137,8 +154,8 @@ div.issue div.subject div div {padding-left:0px; padding-right:16px;}
div.issue span.private, div.journal span.private {margin-right:0px; margin-left:2px;}
-fieldset.collapsible>legend {padding-left:0px; padding-right:18px; background: url(/arrow_down.png) no-repeat right 50%;}
-fieldset.collapsible.collapsed>legend { background-image: url(/arrow_left.png); }
+fieldset.collapsible>legend:not(:has(svg)) {padding-left:0px; padding-right:18px; background: url(/arrow_down.png) no-repeat right 50%;}
+fieldset.collapsible.collapsed>legend:not(:has(svg)) { background-image: url(/arrow_left.png); }
fieldset#filters td.add-filter {text-align:left; }
@@ -213,7 +230,7 @@ fieldset#notified_events .parent {padding-left:0px; padding-right:20px; }
.attachments_fields input.description {margin-left:0px; margin-right:4px;}
.attachments_fields input.filename {background:url(/attachment.png) no-repeat right 1px top 50%; padding-left:0px; padding-right:18px;}
-.attachments_fields .ajax-waiting input.filename {background:url(/hourglass.png) no-repeat right top 50%;}
+.attachments_fields .ajax-waiting input.filename {background:url(/hourglass-empty.svg) no-repeat right top 50%;}
.attachments_fields .ajax-loading input.filename {background:url(/loading.gif) no-repeat right top 50%;}
.attachments_fields div.ui-progressbar {margin: 2px 8px -5px 0;}
@@ -221,7 +238,7 @@ a.remove-upload {background: url(/delete.png) no-repeat right 1px top 50%; paddi
div.thumbnails div {margin-right:0px; margin-left:2px;}
-p.other-formats { text-align:left; }
+p.other-formats, p.query-totals { text-align:left; }
a.atom { background: url(/feed.png) no-repeat right 1px top 50%; padding: 2px 16px 3px 0; }
@@ -231,7 +248,7 @@ table.members td.name {padding-right: 20px; padding-left:0; }
table.members td.group, table.members td.groupnonmember, table.members td.groupanonymous {background: url(/group.png) no-repeat right 50%;}
input.autocomplete {
- background: #fff url(/magnifier.png) no-repeat right 2px top 50%; padding-left:0px !important; padding-right:20px !important;
+ background: #fff url(/search.svg) no-repeat right 2px top 50%; padding-left:0px !important; padding-right:20px !important;
}
.role-visibility {padding-right:2em; padding-left:0;}
@@ -241,16 +258,21 @@ input.autocomplete {
padding: 4px 30px 4px 4px;
}
-div.flash.error, #errorExplanation {
- background: url(/exclamation.png) right 8px top 50% no-repeat;
+div.flash svg.icon-svg, #errorExplanation svg.icon-svg {
+ margin-right: -26px;
+ margin-left: 4px;
}
-div.flash.notice {
- background: url(/true.png) right 8px top 5px no-repeat;
+div.flash.error:not(:has(svg)), #errorExplanation:not(:has(svg)) {
+ background: url(/exclamation.png) right 8px top 50% no-repeat #ffe3e3;
}
-div.flash.warning, .conflict {
- background: url(/warning.png) right 8px top 5px no-repeat;
+div.flash.notice:not(:has(svg)) {
+ background: url(/true.png) right 8px top 5px no-repeat #dfffdf;
+}
+
+div.flash.warning:not(:has(svg)), .conflict {
+ background: url(/warning.png) right 8px top 5px no-repeat #F3EDD1;
text-align:right;
}
@@ -345,11 +367,15 @@ a.wiki-anchor {margin-left:0px; margin-right:6px;}
.project.marker {margin-left:0; margin-right:-4px;}
/***** Icons *****/
-.icon {
+.icon:not(:has(svg)) {
background-position: right 50%;
padding-left:0; padding-right:20px;
}
+svg.icon-svg.icon-rtl {
+ transform: scaleX(-1);
+}
+
div.issue img.gravatar {
float: right;
margin: 0 0 0 6px;
@@ -360,6 +386,10 @@ div.issue table img.gravatar {
margin: 0 0em 0 0.5em;
}
+span.icon-label {
+ margin-right: 4px;
+}
+
h2 img.gravatar {margin: -2px 0 -4px 4px;}
h3 img.gravatar {margin: -4px 0 -4px 4px;}
h4 img.gravatar {margin: -6px 0 -4px 4px;}
diff --git a/app/assets/stylesheets/scm.css b/app/assets/stylesheets/scm.css
index e3eca24df..0df148f8e 100644
--- a/app/assets/stylesheets/scm.css
+++ b/app/assets/stylesheets/scm.css
@@ -19,24 +19,48 @@ div.revision-graph { position: absolute; min-width: 1px; }
div.changeset-changes ul { margin: 0; padding: 0; }
div.changeset-changes ul > ul { margin-left: 18px; padding: 0; }
+div.changeset-changes ul:first-child > li {padding-left: 0}
li.change {
list-style-type:none;
- background-image: url(/bullet_black.png);
- background-position: 1px 2px;
- background-repeat: no-repeat;
padding-top: 1px;
padding-bottom: 1px;
padding-left: 20px;
margin: 0;
}
-li.change.folder { background-image: url(/folder_open.png); }
+li.change:not(:has(svg)) {
+ background-image: url(/bullet_black.png);
+ background-position: 1px 2px;
+ background-repeat: no-repeat;
+}
+li.change.folder:not(:has(svg)) { background-image: url(/folder_open.png); }
li.change.folder.change-A { background-image: url(/folder_open_add.png); }
li.change.folder.change-M { background-image: url(/folder_open_orange.png); }
-li.change.change-A { background-image: url(/bullet_add.png); }
-li.change.change-M { background-image: url(/bullet_orange.png); }
-li.change.change-C { background-image: url(/bullet_blue.png); }
-li.change.change-R { background-image: url(/bullet_purple.png); }
-li.change.change-D { background-image: url(/bullet_delete.png); }
+
+li.change.change-A:not(:has(svg)) { background-image: url(/bullet_add.png); }
+li.change.change-A svg.icon-svg {
+ fill: #5db651;
+ stroke: #ffffff;
+}
+li.change.change-M:not(:has(svg)) { background-image: url(/bullet_orange.png); }
+li.change.change-M svg.icon-svg {
+ fill: #f0a810;
+ stroke: #ffffff;
+}
+li.change.change-C:not(:has(svg)) { background-image: url(/bullet_blue.png); }
+li.change.change-C svg.icon-svg {
+ fill: #049cec;
+ stroke: #ffffff;
+}
+li.change.change-R:not(:has(svg)) { background-image: url(/bullet_purple.png); }
+li.change.change-R svg.icon-svg {
+ fill: #8404ee;
+ stroke: #ffffff;
+}
+li.change.change-D:not(:has(svg)) { background-image: url(/bullet_delete.png); }
+li.change.change-D svg.icon-svg {
+ fill: #c61a1a;
+ stroke: #ffffff;
+}
li.change .copied-from { font-style: italic; color: #999; font-size: 0.9em; }
li.change .copied-from:before { content: " - "}
diff --git a/app/assets/stylesheets/wiki_syntax.css b/app/assets/stylesheets/wiki_syntax.css
index d326a3293..89b117419 100644
--- a/app/assets/stylesheets/wiki_syntax.css
+++ b/app/assets/stylesheets/wiki_syntax.css
@@ -1,19 +1,33 @@
@font-face {
- font-family: "Noto Sans";
- src: url("/NotoSans-VariableFont_wdth,wght.woff2") format("woff2");
- font-weight: 100 900;
- font-stretch: 75% 125%;
- font-style: normal;
- font-display: swap;
+ font-family: "Noto Sans";
+ src: url("/NotoSans-Regular.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
}
@font-face {
- font-family: "Noto Sans";
- src: url("/NotoSans-Italic-VariableFont_wdth,wght.woff2") format("woff2");
- font-weight: 100 900;
- font-stretch: 75% 125%;
- font-style: italic;
- font-display: swap;
+ font-family: "Noto Sans";
+ src: url("/NotoSans-Bold.woff2") format("woff2");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Noto Sans";
+ src: url("/NotoSans-Italic.woff2") format("woff2");
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Noto Sans";
+ src: url("/NotoSans-BoldItalic.woff2") format("woff2");
+ font-weight: 700;
+ font-style: italic;
+ font-display: swap;
}
:root {
@@ -58,3 +72,14 @@ a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
.syntaxhl .s1 { background-color: #fff0f0 }
span.more_info { font-weight: normal; }
+
+.markdown-alert {
+ border-left: 4px solid;
+ padding-left: 10px;
+ margin-left: 10px;
+}
+.markdown-alert-title {
+ font-weight: bold;
+}
+.markdown-alert-note { border-color: #169; }
+.markdown-alert-note .markdown-alert-title { color: #1e40af; } \ No newline at end of file
diff --git a/app/assets/stylesheets/wiki_syntax_detailed.css b/app/assets/stylesheets/wiki_syntax_detailed.css
index 7d7c30f53..ad3c8c65f 100644
--- a/app/assets/stylesheets/wiki_syntax_detailed.css
+++ b/app/assets/stylesheets/wiki_syntax_detailed.css
@@ -1,19 +1,33 @@
@font-face {
- font-family: "Noto Sans";
- src: url("/NotoSans-VariableFont_wdth,wght.woff2") format("woff2");
- font-weight: 100 900;
- font-stretch: 75% 125%;
- font-style: normal;
- font-display: swap;
+ font-family: "Noto Sans";
+ src: url("/NotoSans-Regular.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
}
@font-face {
- font-family: "Noto Sans";
- src: url("/NotoSans-Italic-VariableFont_wdth,wght.woff2") format("woff2");
- font-weight: 100 900;
- font-stretch: 75% 125%;
- font-style: italic;
- font-display: swap;
+ font-family: "Noto Sans";
+ src: url("/NotoSans-Bold.woff2") format("woff2");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Noto Sans";
+ src: url("/NotoSans-Italic.woff2") format("woff2");
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Noto Sans";
+ src: url("/NotoSans-BoldItalic.woff2") format("woff2");
+ font-weight: 700;
+ font-style: italic;
+ font-display: swap;
}
:root {
@@ -49,3 +63,23 @@ table.list td { background-color: #f5f5f5; vertical-align: middle; padding: 0.3e
.syntaxhl .o { color: #333333 }
.syntaxhl .s2 { background-color: #fff0f0 }
.syntaxhl .si { background-color: #eeeeee }
+
+
+.markdown-alert {
+ border-left: 4px solid;
+ padding-left: 10px;
+ margin-left: 20px;
+}
+.markdown-alert-title {
+ font-weight: bold;
+}
+.markdown-alert-tip { border-color: #5db651; }
+.markdown-alert-tip .markdown-alert-title { color: #005f00; }
+.markdown-alert-important { border-color: #800080; }
+.markdown-alert-important .markdown-alert-title { color: #4b006e; }
+.markdown-alert-caution { border-color: #c22; }
+.markdown-alert-caution .markdown-alert-title { color: #880000; }
+.markdown-alert-warning { border-color: #e4bc4b; }
+.markdown-alert-warning .markdown-alert-title { color: #a7760c; }
+.markdown-alert-note { border-color: #169; }
+.markdown-alert-note .markdown-alert-title { color: #1e40af; } \ No newline at end of file
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 074392709..a01d5c75f 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -131,6 +131,14 @@ class ApplicationController < ActionController::Base
if (key = api_key_from_request)
# Use API key
user = User.find_by_api_key(key)
+ elsif access_token = Doorkeeper.authenticate(request)
+ # Oauth
+ if access_token.accessible?
+ user = User.active.find_by_id(access_token.resource_owner_id)
+ user.oauth_scope = access_token.scopes.all.map(&:to_sym)
+ else
+ doorkeeper_render_error
+ end
elsif /\ABasic /i.match?(request.authorization.to_s)
# HTTP Basic, either username/password or API key/random
authenticate_with_http_basic do |username, password|
diff --git a/app/controllers/auto_completes_controller.rb b/app/controllers/auto_completes_controller.rb
index 2982447e9..77105c8e8 100644
--- a/app/controllers/auto_completes_controller.rb
+++ b/app/controllers/auto_completes_controller.rb
@@ -26,7 +26,7 @@ class AutoCompletesController < ApplicationController
status = params[:status].to_s
issue_id = params[:issue_id].to_s
- scope = Issue.cross_project_scope(@project, params[:scope]).visible
+ scope = Issue.cross_project_scope(@project, params[:scope]).includes(:tracker).visible
scope = scope.open(status == 'o') if status.present?
scope = scope.where.not(:id => issue_id.to_i) if issue_id.present?
if q.present?
diff --git a/app/controllers/context_menus_controller.rb b/app/controllers/context_menus_controller.rb
index 4c7305873..1e37f623b 100644
--- a/app/controllers/context_menus_controller.rb
+++ b/app/controllers/context_menus_controller.rb
@@ -33,7 +33,7 @@ class ContextMenusController < ApplicationController
@can = {
:edit => @issues.all?(&:attributes_editable?),
- :log_time => (@project && User.current.allowed_to?(:log_time, @project)),
+ :log_time => @issue&.time_loggable?,
:copy => User.current.allowed_to?(:copy_issues, @projects) && Issue.allowed_target_projects.any?,
:add_watchers => User.current.allowed_to?(:add_issue_watchers, @projects),
:delete => @issues.all?(&:deletable?),
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
index 5159bf540..8b26bee73 100644
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb
@@ -51,6 +51,8 @@ class MessagesController < ApplicationController
offset(@reply_pages.offset).
to_a
+ Message.preload_reaction_details(@replies)
+
@reply = Message.new(:subject => "RE: #{@message.subject}")
render :action => "show", :layout => false if request.xhr?
end
@@ -134,7 +136,7 @@ class MessagesController < ApplicationController
def preview
message = @board.messages.find_by_id(params[:id])
- @text = params[:text] ? params[:text] : nil
+ @text = params[:text] || nil
@previewed = message
render :partial => 'common/preview'
end
diff --git a/app/controllers/news_controller.rb b/app/controllers/news_controller.rb
index 06240e359..dd6bade24 100644
--- a/app/controllers/news_controller.rb
+++ b/app/controllers/news_controller.rb
@@ -67,8 +67,10 @@ class NewsController < ApplicationController
end
def show
- @comments = @news.comments.to_a
+ @comments = @news.comments.preload(:commented).to_a
@comments.reverse! if User.current.wants_comments_in_reverse_order?
+
+ Comment.preload_reaction_details(@comments)
end
def new
diff --git a/app/controllers/oauth2_applications_controller.rb b/app/controllers/oauth2_applications_controller.rb
new file mode 100644
index 000000000..107af2ec0
--- /dev/null
+++ b/app/controllers/oauth2_applications_controller.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+#
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+class Oauth2ApplicationsController < Doorkeeper::ApplicationsController
+ private
+
+ def application_params
+ params[:doorkeeper_application] ||= {}
+ params[:doorkeeper_application][:scopes] ||= []
+
+ scopes = Redmine::AccessControl.public_permissions.map{|p| p.name.to_s}
+
+ if params[:doorkeeper_application][:scopes].is_a?(Array)
+ scopes |= params[:doorkeeper_application][:scopes]
+ else
+ scopes |= params[:doorkeeper_application][:scopes].split(/\s+/)
+ end
+ params[:doorkeeper_application][:scopes] = scopes.join(' ')
+ super
+ end
+end
diff --git a/app/controllers/previews_controller.rb b/app/controllers/previews_controller.rb
index 9dd228a3d..744daa7c8 100644
--- a/app/controllers/previews_controller.rb
+++ b/app/controllers/previews_controller.rb
@@ -26,7 +26,7 @@ class PreviewsController < ApplicationController
if @issue
@previewed = @issue
end
- @text = params[:text] ? params[:text] : nil
+ @text = params[:text] || nil
render :partial => 'common/preview'
end
@@ -34,12 +34,12 @@ class PreviewsController < ApplicationController
if params[:id].present? && news = News.visible.find_by_id(params[:id])
@previewed = news
end
- @text = params[:text] ? params[:text] : nil
+ @text = params[:text] || nil
render :partial => 'common/preview'
end
def text
- @text = params[:text] ? params[:text] : nil
+ @text = params[:text] || nil
render :partial => 'common/preview'
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index f9a390c58..2a42c99ed 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -176,7 +176,7 @@ class ProjectsController < ApplicationController
respond_to do |format|
format.html do
@principals_by_role = @project.principals_by_role
- @subprojects = @project.children.visible.to_a
+ @subprojects = @project.leaf? ? [] : @project.children.visible.to_a
@news = @project.news.limit(5).includes(:author, :project).reorder("#{News.table_name}.created_on DESC").to_a
with_subprojects = Setting.display_subprojects_issues?
@trackers = @project.rolled_up_trackers(with_subprojects).visible
diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb
new file mode 100644
index 000000000..71b37e5f8
--- /dev/null
+++ b/app/controllers/reactions_controller.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+class ReactionsController < ApplicationController
+ before_action :require_login
+
+ before_action :check_enabled
+ before_action :set_object, :authorize_reactable
+
+ def create
+ respond_to do |format|
+ format.js do
+ @object.reactions.find_or_create_by!(user: User.current)
+ end
+ format.any { head :not_found }
+ end
+ end
+
+ def destroy
+ respond_to do |format|
+ format.js do
+ reaction = @object.reactions.by(User.current).find_by(id: params[:id])
+ reaction&.destroy
+ end
+ format.any { head :not_found }
+ end
+ end
+
+ private
+
+ def check_enabled
+ render_403 unless Setting.reactions_enabled?
+ end
+
+ def set_object
+ object_type = params[:object_type]
+
+ unless Redmine::Reaction::REACTABLE_TYPES.include?(object_type)
+ render_403
+ return
+ end
+
+ @object = object_type.constantize.find(params[:object_id])
+ end
+
+ def authorize_reactable
+ render_403 unless Redmine::Reaction.editable?(@object, User.current)
+ end
+end
diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb
index 9be7878ce..d6a13daf2 100644
--- a/app/controllers/repositories_controller.rb
+++ b/app/controllers/repositories_controller.rb
@@ -160,7 +160,15 @@ class RepositoriesController < ApplicationController
# Force the download
send_opt = {:filename => filename_for_content_disposition(@path.split('/').last)}
send_type = Redmine::MimeType.of(@path)
- send_opt[:type] = send_type.to_s if send_type
+ case send_type
+ when nil
+ # No MIME type detected. Let Rails use the default type.
+ when 'application/javascript'
+ # Avoid ActionController::InvalidCrossOriginRequest exception by setting non-JS content type
+ send_opt[:type] = 'text/plain'
+ else
+ send_opt[:type] = send_type
+ end
send_opt[:disposition] = disposition(@path)
send_data @repository.cat(@path, @rev), send_opt
else
diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb
index dfe7c2b8f..89f9ee497 100644
--- a/app/controllers/roles_controller.rb
+++ b/app/controllers/roles_controller.rb
@@ -99,7 +99,15 @@ class RolesController < ApplicationController
begin
@role.destroy
rescue
- flash[:error] = l(:error_can_not_remove_role)
+ flash[:error] = l(:error_can_not_remove_role)
+
+ if @role.members.present?
+ projects = Project.joins(members: :member_roles).where(member_roles: { role_id: @role.id }).distinct.sorted
+ links = projects.map do |p|
+ view_context.link_to(p, settings_project_path(p, tab: 'members'))
+ end.join(', ')
+ flash[:error] += l(:error_can_not_remove_role_reason_members_html, projects: links)
+ end
end
redirect_to roles_path
end
diff --git a/app/controllers/versions_controller.rb b/app/controllers/versions_controller.rb
index d52b43ba3..328d3e56e 100644
--- a/app/controllers/versions_controller.rb
+++ b/app/controllers/versions_controller.rb
@@ -51,7 +51,7 @@ class VersionsController < ApplicationController
if @selected_tracker_ids.any? && @versions.any?
issues = Issue.visible.
includes(:project, :tracker).
- preload(:status, :priority, :fixed_version).
+ preload(:status, :priority, :fixed_version, {:assigned_to => :email_address}).
where(:tracker_id => @selected_tracker_ids, :project_id => project_ids, :fixed_version_id => @versions.map(&:id)).
order("#{Project.table_name}.lft, #{Tracker.table_name}.position, #{Issue.table_name}.id")
@issues_by_version = issues.group_by(&:fixed_version)
@@ -69,7 +69,7 @@ class VersionsController < ApplicationController
format.html do
@issues = @version.fixed_issues.visible.
includes(:status, :tracker, :priority).
- preload(:project).
+ preload(:project, {:assigned_to => :email_address}).
reorder("#{Tracker.table_name}.position, #{Issue.table_name}.id").
to_a
end
diff --git a/app/controllers/wiki_controller.rb b/app/controllers/wiki_controller.rb
index 36b90da77..bcb3b0891 100644
--- a/app/controllers/wiki_controller.rb
+++ b/app/controllers/wiki_controller.rb
@@ -240,6 +240,7 @@ class WikiController < ApplicationController
# don't load text
@versions = @page.content.versions.
select("id, author_id, comments, updated_on, version").
+ preload(:author).
reorder('version DESC').
limit(@version_pages.per_page + 1).
offset(@version_pages.offset).
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 50519c890..ab418fb38 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -128,7 +128,7 @@ module ApplicationHelper
# * :download - Force download (default: false)
def link_to_attachment(attachment, options={})
text = options.delete(:text) || attachment.filename
- icon = options.fetch(:icon, false)
+ icon = options.delete(:icon)
if options.delete(:download)
route_method = :download_named_attachment_url
@@ -436,7 +436,7 @@ module ApplicationHelper
def format_changeset_comments(changeset, options={})
method = options[:short] ? :short_comments : :comments
- textilizable changeset, method, :formatting => Setting.commit_logs_formatting?
+ textilizable changeset, method, project: changeset.project, formatting: Setting.commit_logs_formatting?
end
def due_date_distance_in_words(date)
@@ -518,7 +518,9 @@ module ApplicationHelper
def render_flash_messages
s = +''
flash.each do |k, v|
- s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
+ next unless v.is_a?(String)
+
+ s << content_tag('div', notice_icon(k) + v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
end
s.html_safe
end
@@ -789,7 +791,7 @@ module ApplicationHelper
end
def other_formats_links(&)
- concat('<p class="other-formats">'.html_safe + l(:label_export_to))
+ concat('<p class="other-formats hide-when-print">'.html_safe + l(:label_export_to))
yield Redmine::Views::OtherFormatsBuilder.new(self)
concat('</p>'.html_safe)
end
@@ -1386,7 +1388,7 @@ module ApplicationHelper
<|
$)
}x
- HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
+ HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/im unless const_defined?(:HEADING_RE)
def parse_sections(text, project, obj, attr, only_path, options)
return unless options[:edit_section_links]
@@ -1576,7 +1578,9 @@ module ApplicationHelper
def render_error_messages(errors)
html = +""
if errors.present?
- html << "<div id='errorExplanation'><ul>\n"
+ html << "<div id='errorExplanation'>\n"
+ html << notice_icon('error')
+ html << "<ul>\n"
errors.each do |error|
html << "<li>#{h error}</li>\n"
end
@@ -1605,7 +1609,7 @@ module ApplicationHelper
# Helper to render JSON in views
def raw_json(arg)
- arg.to_json.to_s.gsub('/', '\/').html_safe
+ arg.to_json.gsub('/', '\/').html_safe
end
def back_url_hidden_field_tag
@@ -1803,7 +1807,7 @@ module ApplicationHelper
if Setting.wiki_tablesort_enabled?
tags << javascript_include_tag('tablesort-5.2.1.min.js', 'tablesort-5.2.1.number.min.js')
end
- tags << javascript_include_tag('application', 'responsive')
+ tags << javascript_include_tag('application-legacy', 'responsive')
unless User.current.pref.warn_on_leaving_unsaved == '0'
warn_text = escape_javascript(l(:text_warn_on_leaving_unsaved))
tags <<
@@ -1915,6 +1919,14 @@ module ApplicationHelper
end
end
+ def heads_for_i18n
+ javascript_tag(
+ "rm = window.rm || {};" \
+ "rm.I18n = rm.I18n || {};" \
+ "rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}'});"
+ )
+ end
+
def heads_for_auto_complete(project)
data_sources = autocomplete_data_sources(project)
javascript_tag(
@@ -1932,7 +1944,7 @@ module ApplicationHelper
def copy_object_url_link(url)
link_to_function(
- sprite_icon('copy-link', l(:button_copy_link)), 'copyTextToClipboard(this);',
+ sprite_icon('copy-link', l(:button_copy_link)), 'copyDataClipboardTextToClipboard(this);',
class: 'icon icon-copy-link',
data: {'clipboard-text' => url}
)
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index b39427bda..cd9c1d66e 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -44,6 +44,7 @@ module AvatarsHelper
if user.respond_to?(:mail)
email = user.mail
options[:title] = user.name unless options[:title]
+ options[:initials] = user.initials if options[:default] == "initials" && user.initials.present?
elsif user.to_s =~ %r{<(.+?)>}
email = $1
end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 99006308e..6afb84537 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -21,10 +21,10 @@ module IconsHelper
DEFAULT_ICON_SIZE = "18"
DEFAULT_SPRITE = "icons"
- def sprite_icon(icon_name, label = nil, icon_only: false, size: DEFAULT_ICON_SIZE, css_class: nil, sprite: DEFAULT_SPRITE, plugin: nil)
+ def sprite_icon(icon_name, label = nil, icon_only: false, size: DEFAULT_ICON_SIZE, style: :outline, css_class: nil, sprite: DEFAULT_SPRITE, plugin: nil, rtl: false)
sprite = plugin ? "plugin_assets/#{plugin}/#{sprite}.svg" : "#{sprite}.svg"
- svg_icon = svg_sprite_icon(icon_name, size: size, css_class: css_class, sprite: sprite)
+ svg_icon = svg_sprite_icon(icon_name, size: size, style: style, css_class: css_class, sprite: sprite, rtl: rtl)
if label
label_classes = ["icon-label"]
@@ -36,23 +36,23 @@ module IconsHelper
end
end
- def file_icon(entry, name, **options)
+ def file_icon(entry, name, **)
if entry.is_dir?
- sprite_icon("folder", name, **options)
+ sprite_icon("folder", name, **)
else
icon_name = icon_for_mime_type(Redmine::MimeType.css_class_of(name))
- sprite_icon(icon_name, name, **options)
+ sprite_icon(icon_name, name, **)
end
end
- def principal_icon(principal, **options)
+ def principal_icon(principal, **)
raise ArgumentError, "First argument has to be a Principal, was #{principal.inspect}" unless principal.is_a?(Principal)
principal_class = principal.class.name.downcase
- sprite_icon('group', **options) if ['groupanonymous', 'groupnonmember', 'group'].include?(principal_class)
+ sprite_icon('group', **) if ['groupanonymous', 'groupnonmember', 'group'].include?(principal_class)
end
- def activity_event_type_icon(event_type, **options)
+ def activity_event_type_icon(event_type, **)
icon_name = case event_type
when 'reply'
'comments'
@@ -64,14 +64,39 @@ module IconsHelper
event_type
end
- sprite_icon(icon_name, **options)
+ sprite_icon(icon_name, **)
+ end
+
+ def scm_change_icon(action, name, **options)
+ icon_name = case action
+ when 'A'
+ "add"
+ when 'D'
+ "circle-minus"
+ else
+ "circle-dot-filled"
+ end
+ sprite_icon(icon_name, name, size: 14)
+ end
+
+ def notice_icon(type, **)
+ icon_name = case type
+ when 'notice'
+ 'checked'
+ when 'warning', 'error'
+ 'warning'
+ end
+
+ sprite_icon(icon_name, **)
end
private
- def svg_sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, sprite: DEFAULT_SPRITE, css_class: nil)
+ def svg_sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, style: :outline, sprite: DEFAULT_SPRITE, css_class: nil, rtl: false)
css_classes = "s#{size} icon-svg"
+ css_classes += " icon-svg-filled" if style == :filled
css_classes += " #{css_class}" unless css_class.nil?
+ css_classes += " icon-rtl" if rtl
content_tag(
:svg,
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index b4c87f758..ce3607a5d 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -21,6 +21,8 @@ module IssuesHelper
include ApplicationHelper
include Redmine::Export::PDF::IssuesPdfHelper
include IssueStatusesHelper
+ include QueriesHelper
+ include ReactionsHelper
def issue_list(issues, &)
ancestors = []
@@ -89,9 +91,28 @@ module IssuesHelper
s.html_safe
end
+ def get_related_issues_columns_for_project(issue)
+ query = IssueQuery.new project: issue.project
+ available_columns = query.available_inline_columns
+ column_names = Setting.related_issues_default_columns
+
+ (column_names - %w[tracker subject]).filter_map do |name|
+ available_columns.find { |f| f.name.to_s == name }
+ end
+ end
+
def render_descendants_tree(issue)
+ columns_list = get_related_issues_columns_for_project(issue)
+
manage_relations = User.current.allowed_to?(:manage_subtasks, issue.project)
s = +'<table class="list issues odd-even">'
+
+ if Setting.display_related_issues_table_headers?
+ headers = [l(:field_subject)]
+ headers += columns_list.map(&:caption)
+ s << content_tag(:thead, content_tag(:tr, safe_join(headers.map{|h| content_tag :th, h})), class: "related-issues")
+ end
+
issue_list(
issue.descendants.visible.
preload(:status, :priority, :tracker,
@@ -115,29 +136,17 @@ module IssuesHelper
"".html_safe
end
buttons << link_to_context_menu
- s <<
- content_tag(
- 'tr',
- content_tag('td', check_box_tag("ids[]", child.id, false, :id => nil),
- :class => 'checkbox') +
- content_tag('td',
- link_to_issue(
- child,
- :project => (issue.project_id != child.project_id)),
- :class => 'subject') +
- content_tag('td', h(child.status), :class => 'status') +
- content_tag('td', link_to_user(child.assigned_to), :class => 'assigned_to') +
- content_tag('td', format_date(child.start_date), :class => 'start_date') +
- content_tag('td', format_date(child.due_date), :class => 'due_date') +
- content_tag('td',
- (if child.disabled_core_fields.include?('done_ratio')
- ''
- else
- progress_bar(child.done_ratio)
- end),
- :class=> 'done_ratio') +
- content_tag('td', buttons, :class => 'buttons'),
- :class => css)
+
+ row_content =
+ content_tag('td', check_box_tag('ids[]', child.id, false, id: nil), class: 'checkbox') +
+ content_tag('td', link_to_issue(child, project: (issue.project_id != child.project_id)), class: 'subject')
+
+ columns_list.each do |column|
+ row_content << content_tag('td', column_content(column, child), class: column.css_classes.to_s)
+ end
+
+ row_content << content_tag('td', buttons, class: 'buttons')
+ s << content_tag('tr', row_content, class: css, id: "issue-#{child.id}").html_safe
end
s << '</table>'
s.html_safe
@@ -199,8 +208,17 @@ module IssuesHelper
# Renders the list of related issues on the issue details view
def render_issue_relations(issue, relations)
+ columns_list = get_related_issues_columns_for_project(issue)
+
manage_relations = User.current.allowed_to?(:manage_issue_relations, issue.project)
s = ''.html_safe
+
+ if Setting.display_related_issues_table_headers?
+ headers = [l(:field_subject)]
+ headers += columns_list.map(&:caption)
+ s = content_tag :thead, content_tag(:tr, safe_join(headers.map{|h| content_tag :th, h})), class: "related-issues"
+ end
+
relations.each do |relation|
other_issue = relation.other_issue(issue)
css = "issue hascontextmenu #{other_issue.css_classes} #{relation.css_classes_for(other_issue)}"
@@ -219,36 +237,19 @@ module IssuesHelper
"".html_safe
end
buttons << link_to_context_menu
- s <<
- content_tag(
- 'tr',
- content_tag('td',
- check_box_tag(
- "ids[]", other_issue.id,
- false, :id => nil),
- :class => 'checkbox') +
- content_tag('td',
- relation.to_s(@issue) do |other|
- link_to_issue(
- other,
- :project => Setting.cross_project_issue_relations?
- )
- end.html_safe,
- :class => 'subject') +
- content_tag('td', other_issue.status, :class => 'status') +
- content_tag('td', link_to_user(other_issue.assigned_to), :class => 'assigned_to') +
- content_tag('td', format_date(other_issue.start_date), :class => 'start_date') +
- content_tag('td', format_date(other_issue.due_date), :class => 'due_date') +
- content_tag('td',
- (if other_issue.disabled_core_fields.include?('done_ratio')
- ''
- else
- progress_bar(other_issue.done_ratio)
- end),
- :class=> 'done_ratio') +
- content_tag('td', buttons, :class => 'buttons'),
- :id => "relation-#{relation.id}",
- :class => css)
+
+ subject_content = relation.to_s(@issue) { |other| link_to_issue other, project: Setting.cross_project_issue_relations? }.html_safe
+
+ row_content =
+ content_tag('td', check_box_tag('ids[]', other_issue.id, false, id: nil), class: 'checkbox') +
+ content_tag('td', subject_content, class: 'subject')
+
+ columns_list.each do |column|
+ row_content << content_tag('td', column_content(column, other_issue), class: column.css_classes.to_s)
+ end
+
+ row_content << content_tag('td', buttons, class: 'buttons')
+ s << content_tag('tr', row_content, id: "relation-#{relation.id}", class: css)
end
content_tag('table', s, :class => 'list issues odd-even')
end
diff --git a/app/helpers/journals_helper.rb b/app/helpers/journals_helper.rb
index 6c22fc4ca..0ddbc34b8 100644
--- a/app/helpers/journals_helper.rb
+++ b/app/helpers/journals_helper.rb
@@ -19,6 +19,7 @@
module JournalsHelper
include Redmine::QuoteReply::Helper
+ include ReactionsHelper
# Returns the attachments of a journal that are displayed as thumbnails
def journal_thumbnail_attachments(journal)
@@ -40,10 +41,12 @@ module JournalsHelper
)
end
+ links << reaction_button(journal)
+
if journal.notes.present?
if options[:reply_links]
url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
- links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
+ links << quote_reply_button(url: url, icon_only: true)
end
if journal.editable_by?(User.current)
links << link_to(sprite_icon('edit', l(:button_edit)),
@@ -66,7 +69,8 @@ module JournalsHelper
end
def render_notes(issue, journal, options={})
- content_tag('div', textilizable(journal, :notes), :id => "journal-#{journal.id}-notes", :class => "wiki")
+ content_tag('div', textilizable(journal, :notes),
+ id: "journal-#{journal.id}-notes", class: "wiki journal-note", data: { quote_reply_target: 'content' })
end
def render_private_notes_indicator(journal)
diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb
index fd9ba3bcb..92f788d0c 100644
--- a/app/helpers/messages_helper.rb
+++ b/app/helpers/messages_helper.rb
@@ -19,4 +19,5 @@
module MessagesHelper
include Redmine::QuoteReply::Helper
+ include ReactionsHelper
end
diff --git a/app/helpers/news_helper.rb b/app/helpers/news_helper.rb
index a5c50fdfd..cd7b6734a 100644
--- a/app/helpers/news_helper.rb
+++ b/app/helpers/news_helper.rb
@@ -18,4 +18,5 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module NewsHelper
+ include ReactionsHelper
end
diff --git a/app/helpers/principal_memberships_helper.rb b/app/helpers/principal_memberships_helper.rb
index d9caf4f50..e69324247 100644
--- a/app/helpers/principal_memberships_helper.rb
+++ b/app/helpers/principal_memberships_helper.rb
@@ -38,27 +38,27 @@ module PrincipalMembershipsHelper
end
end
- def new_principal_membership_path(principal, *args)
+ def new_principal_membership_path(principal, *)
if principal.is_a?(Group)
- new_group_membership_path(principal, *args)
+ new_group_membership_path(principal, *)
else
- new_user_membership_path(principal, *args)
+ new_user_membership_path(principal, *)
end
end
- def edit_principal_membership_path(principal, *args)
+ def edit_principal_membership_path(principal, *)
if principal.is_a?(Group)
- edit_group_membership_path(principal, *args)
+ edit_group_membership_path(principal, *)
else
- edit_user_membership_path(principal, *args)
+ edit_user_membership_path(principal, *)
end
end
- def principal_membership_path(principal, membership, *args)
+ def principal_membership_path(principal, membership, *)
if principal.is_a?(Group)
- group_membership_path(principal, membership, *args)
+ group_membership_path(principal, membership, *)
else
- user_membership_path(principal, membership, *args)
+ user_membership_path(principal, membership, *)
end
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 01a5452f7..bae1c4e3a 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -80,8 +80,8 @@ module ProjectsHelper
classes += %w(icon icon-bookmarked-project) if bookmarked_project_ids.include?(project.id)
s = link_to_project(project, {}, :class => classes.uniq.join(' '))
- s << sprite_icon('user', l(:label_my_projects), icon_only: true) if User.current.member_of?(project)
- s << sprite_icon('bookmarked', l(:label_my_bookmarks), icon_only: true) if bookmarked_project_ids.include?(project.id)
+ s << tag.span(sprite_icon('user', l(:label_my_projects), icon_only: true), class: 'icon-only icon-user my-project') if User.current.member_of?(project)
+ s << tag.span(sprite_icon('bookmarked', l(:label_my_bookmarks), icon_only: true), class: 'icon-only icon-bookmarked-project') if bookmarked_project_ids.include?(project.id)
if project.description.present?
s << content_tag('div', textilizable(project.short_description, :project => project), :class => 'wiki description')
end
diff --git a/app/helpers/queries_helper.rb b/app/helpers/queries_helper.rb
index 3775dcd3f..3aef7083a 100644
--- a/app/helpers/queries_helper.rb
+++ b/app/helpers/queries_helper.rb
@@ -169,7 +169,7 @@ module QueriesHelper
group_name = format_object(group)
end
group_name ||= ""
- group_count = result_count_by_group ? result_count_by_group[group] : nil
+ group_count = result_count_by_group&.[](group)
group_totals = totals_by_group.map {|column, t| total_tag(column, t[group] || 0)}.join(" ").html_safe
end
end
@@ -192,6 +192,8 @@ module QueriesHelper
value =
if [:hours, :spent_hours, :total_spent_hours, :estimated_hours, :total_estimated_hours, :estimated_remaining_hours].include? column.name
format_hours(value)
+ elsif column.is_a?(QueryCustomFieldColumn)
+ format_object(value, thousands_delimiter: column.custom_field.thousands_delimiter?)
else
format_object(value)
end
diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb
new file mode 100644
index 000000000..e02e1c9f9
--- /dev/null
+++ b/app/helpers/reactions_helper.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+module ReactionsHelper
+ # Maximum number of users to display in the reaction button tooltip
+ DISPLAY_REACTION_USERS_LIMIT = 10
+
+ def reaction_button(object)
+ return unless Redmine::Reaction.visible?(object, User.current)
+
+ detail = object.reaction_detail
+
+ user_reaction = detail.user_reaction
+ count = detail.reaction_count
+ visible_user_names = detail.visible_users.take(DISPLAY_REACTION_USERS_LIMIT).map(&:name)
+
+ tooltip = build_reaction_tooltip(visible_user_names, count)
+
+ if Redmine::Reaction.editable?(object, User.current)
+ if user_reaction.present?
+ reaction_button_reacted(object, user_reaction, count, tooltip)
+ else
+ reaction_button_not_reacted(object, count, tooltip)
+ end
+ else
+ reaction_button_readonly(object, count, tooltip)
+ end
+ end
+
+ def reaction_id_for(object)
+ dom_id(object, :reaction)
+ end
+
+ private
+
+ def reaction_button_reacted(object, reaction, count, tooltip)
+ reaction_button_wrapper object do
+ link_to(
+ sprite_icon('thumb-up-filled', count.nonzero?, style: :filled),
+ reaction_path(reaction, object_type: object.class.name, object_id: object),
+ remote: true, method: :delete,
+ class: ['icon', 'reaction-button', 'reacted'],
+ title: tooltip
+ )
+ end
+ end
+
+ def reaction_button_not_reacted(object, count, tooltip)
+ reaction_button_wrapper object do
+ link_to(
+ sprite_icon('thumb-up', count.nonzero?),
+ reactions_path(object_type: object.class.name, object_id: object),
+ remote: true, method: :post,
+ class: 'icon reaction-button',
+ title: tooltip
+ )
+ end
+ end
+
+ def reaction_button_readonly(object, count, tooltip)
+ reaction_button_wrapper object do
+ tag.span(class: 'icon reaction-button readonly', title: tooltip) do
+ sprite_icon('thumb-up', count.nonzero?)
+ end
+ end
+ end
+
+ def reaction_button_wrapper(object, &)
+ tag.span(class: 'reaction-button-wrapper', data: { 'reaction-button-id': reaction_id_for(object) }, &)
+ end
+
+ def build_reaction_tooltip(visible_user_names, count)
+ return if count.zero?
+
+ display_user_names = visible_user_names.dup
+ others = count - visible_user_names.size
+
+ if others.positive?
+ display_user_names << I18n.t(:reaction_text_x_other_users, count: others)
+ end
+
+ display_user_names.to_sentence(locale: I18n.locale)
+ end
+end
diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb
index 6390ecbdb..f8df59b00 100644
--- a/app/helpers/reports_helper.rb
+++ b/app/helpers/reports_helper.rb
@@ -34,9 +34,9 @@ module ReportsHelper
a
end
- def aggregate_link(data, criteria, *args)
+ def aggregate_link(data, criteria, *)
a = aggregate data, criteria
- a > 0 ? link_to(h(a), *args) : '-'
+ a > 0 ? link_to(h(a), *) : '-'
end
def aggregate_path(project, field, row, options={})
diff --git a/app/helpers/repositories_helper.rb b/app/helpers/repositories_helper.rb
index 7a6978979..c72816367 100644
--- a/app/helpers/repositories_helper.rb
+++ b/app/helpers/repositories_helper.rb
@@ -96,7 +96,7 @@ module RepositoriesHelper
if s = tree[file][:s]
style << ' folder'
path_param = to_path_param(@repository.relative_path(file))
- text = link_to(h(text), :controller => 'repositories',
+ text = link_to(sprite_icon("folder-open", h(text)), :controller => 'repositories',
:action => 'show',
:id => @project,
:repository_id => @repository.identifier_param,
@@ -108,7 +108,7 @@ module RepositoriesHelper
elsif c = tree[file][:c]
style << " change-#{c.action}"
path_param = to_path_param(@repository.relative_path(c.path))
- text = link_to(h(text), :controller => 'repositories',
+ text = link_to(scm_change_icon(c.action, h(text)), :controller => 'repositories',
:action => 'entry',
:id => @project,
:repository_id => @repository.identifier_param,
diff --git a/app/helpers/routes_helper.rb b/app/helpers/routes_helper.rb
index f5d6dbd38..a27ea783e 100644
--- a/app/helpers/routes_helper.rb
+++ b/app/helpers/routes_helper.rb
@@ -20,83 +20,83 @@
module RoutesHelper
# Returns the path to project issues or to the cross-project
# issue list if project is nil
- def _project_issues_path(project, *args)
+ def _project_issues_path(project, *)
if project
- project_issues_path(project, *args)
+ project_issues_path(project, *)
else
- issues_path(*args)
+ issues_path(*)
end
end
- def _project_issues_url(project, *args)
+ def _project_issues_url(project, *)
if project
- project_issues_url(project, *args)
+ project_issues_url(project, *)
else
- issues_url(*args)
+ issues_url(*)
end
end
- def _project_news_path(project, *args)
+ def _project_news_path(project, *)
if project
- project_news_index_path(project, *args)
+ project_news_index_path(project, *)
else
- news_index_path(*args)
+ news_index_path(*)
end
end
- def _new_project_issue_path(project, *args)
+ def _new_project_issue_path(project, *)
if project
- new_project_issue_path(project, *args)
+ new_project_issue_path(project, *)
else
- new_issue_path(*args)
+ new_issue_path(*)
end
end
- def _project_calendar_path(project, *args)
- project ? project_calendar_path(project, *args) : issues_calendar_path(*args)
+ def _project_calendar_path(project, *)
+ project ? project_calendar_path(project, *) : issues_calendar_path(*)
end
- def _project_gantt_path(project, *args)
- project ? project_gantt_path(project, *args) : issues_gantt_path(*args)
+ def _project_gantt_path(project, *)
+ project ? project_gantt_path(project, *) : issues_gantt_path(*)
end
- def _time_entries_path(project, issue, *args)
+ def _time_entries_path(project, issue, *)
if project
- project_time_entries_path(project, *args)
+ project_time_entries_path(project, *)
else
- time_entries_path(*args)
+ time_entries_path(*)
end
end
- def _report_time_entries_path(project, issue, *args)
+ def _report_time_entries_path(project, issue, *)
if project
- report_project_time_entries_path(project, *args)
+ report_project_time_entries_path(project, *)
else
- report_time_entries_path(*args)
+ report_time_entries_path(*)
end
end
- def _new_time_entry_path(project, issue, *args)
+ def _new_time_entry_path(project, issue, *)
if issue
- new_issue_time_entry_path(issue, *args)
+ new_issue_time_entry_path(issue, *)
elsif project
- new_project_time_entry_path(project, *args)
+ new_project_time_entry_path(project, *)
else
- new_time_entry_path(*args)
+ new_time_entry_path(*)
end
end
# Returns the path to bulk update issues or to issue path
# if only one issue is selected for bulk update
- def _bulk_update_issues_path(issue, *args)
+ def _bulk_update_issues_path(issue, *)
if issue
- issue_path(issue, *args)
+ issue_path(issue, *)
else
- bulk_update_issues_path(*args)
+ bulk_update_issues_path(*)
end
end
- def board_path(board, *args)
- project_board_path(board.project, board, *args)
+ def board_path(board, *)
+ project_board_path(board.project, board, *)
end
end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 1fb57b2d7..c1f989805 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -48,7 +48,11 @@ module SettingsHelper
errors.each do |name, message|
s << content_tag('li', content_tag('b', l("setting_#{name}")) + " " + message)
end
- content_tag('div', content_tag('ul', s), :id => 'errorExplanation')
+
+ h = ''.html_safe
+ h << notice_icon('error')
+ h << content_tag('ul', s)
+ content_tag('div', h, :id => 'errorExplanation')
end
def setting_value(setting)
@@ -240,6 +244,7 @@ module SettingsHelper
['Mystery man', 'mm'],
['Retro', 'retro'],
['Robohash', 'robohash'],
- ['Wavatars', 'wavatar']]
+ ['Wavatars', 'wavatar'],
+ ['Initials', 'initials']]
end
end
diff --git a/app/helpers/watchers_helper.rb b/app/helpers/watchers_helper.rb
index 882325c18..bfed8adf2 100644
--- a/app/helpers/watchers_helper.rb
+++ b/app/helpers/watchers_helper.rb
@@ -26,6 +26,7 @@ module WatchersHelper
watched = Watcher.any_watched?(objects, user)
css = [watcher_css(objects), watched ? 'icon icon-fav' : 'icon icon-fav-off'].join(' ')
+ icon = watched ? 'unwatch' : 'watch'
text = watched ? l(:button_unwatch) : l(:button_watch)
url = watch_path(
:object_type => objects.first.class.to_s.underscore,
@@ -33,7 +34,7 @@ module WatchersHelper
)
method = watched ? 'delete' : 'post'
- link_to sprite_icon('fav', text), url, :remote => true, :method => method, :class => css
+ link_to sprite_icon(icon, text), url, :remote => true, :method => method, :class => css
end
# Returns the css class used to identify watch links for a given +object+
@@ -47,7 +48,9 @@ module WatchersHelper
def watchers_list(object)
remove_allowed = User.current.allowed_to?(:"delete_#{object.class.name.underscore}_watchers", object.project)
content = ''.html_safe
- lis = object.watcher_users.sorted.collect do |user|
+ scope = object.watcher_users
+ scope = scope.includes(:email_address) if Setting.gravatar_enabled?
+ lis = scope.sorted.collect do |user|
s = ''.html_safe
s << avatar(user, :size => "16").to_s if user.is_a?(User)
s << link_to_principal(user, class: user.class.to_s.downcase)
diff --git a/app/javascript/application.js b/app/javascript/application.js
new file mode 100644
index 000000000..72ef077f8
--- /dev/null
+++ b/app/javascript/application.js
@@ -0,0 +1 @@
+import "controllers"
diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js
new file mode 100644
index 000000000..f898b4e6b
--- /dev/null
+++ b/app/javascript/controllers/application.js
@@ -0,0 +1,8 @@
+import { Application } from '@hotwired/stimulus'
+
+const application = Application.start()
+
+application.debug = false
+window.Stimulus = application
+
+export { application }
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
new file mode 100644
index 000000000..6ffb4e9ee
--- /dev/null
+++ b/app/javascript/controllers/index.js
@@ -0,0 +1,3 @@
+import { application } from "controllers/application"
+import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
+eagerLoadControllersFrom("controllers", application)
diff --git a/app/javascript/controllers/quote_reply_controller.js b/app/javascript/controllers/quote_reply_controller.js
new file mode 100644
index 000000000..137c019ac
--- /dev/null
+++ b/app/javascript/controllers/quote_reply_controller.js
@@ -0,0 +1,224 @@
+import { Controller } from '@hotwired/stimulus'
+import TurndownService from 'turndown'
+import { post } from '@rails/request.js'
+
+class QuoteExtractor {
+ constructor(targetElement) {
+ this.targetElement = targetElement;
+ this.selection = window.getSelection();
+ }
+
+ get isSelected() {
+ return this.selection.containsNode(this.targetElement, true);
+ }
+
+ static extract(targetElement) {
+ return new QuoteExtractor(targetElement).extract();
+ }
+
+ extract() {
+ const range = this.retriveSelectedRange();
+
+ if (!range) {
+ return null;
+ }
+
+ if (!this.targetElement.contains(range.startContainer)) {
+ range.setStartBefore(this.targetElement);
+ }
+ if (!this.targetElement.contains(range.endContainer)) {
+ range.setEndAfter(this.targetElement);
+ }
+
+ return range;
+ }
+
+ retriveSelectedRange() {
+ if (!this.isSelected) {
+ return null;
+ }
+
+ // Retrive the first range that intersects with the target element.
+ // NOTE: Firefox allows to select multiple ranges in the document.
+ for (let i = 0; i < this.selection.rangeCount; i++) {
+ let range = this.selection.getRangeAt(i);
+ if (range.intersectsNode(this.targetElement)) {
+ return range;
+ }
+ }
+ return null;
+ }
+}
+
+class QuoteTextFormatter {
+ format(selectedRange) {
+ if (!selectedRange) {
+ return null;
+ }
+
+ const fragment = document.createElement('div');
+ fragment.appendChild(selectedRange.cloneContents());
+
+ // Remove all unnecessary anchor elements
+ fragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
+
+ const html = this.adjustLineBreaks(fragment.innerHTML);
+
+ const result = document.createElement('div');
+ result.innerHTML = html;
+
+ // Replace continuous line breaks with a single line break and remove tab characters
+ return result.textContent
+ .trim()
+ .replace(/\t/g, '')
+ .replace(/\n+/g, "\n");
+ }
+
+ adjustLineBreaks(html) {
+ return html
+ .replace(/<\/(h1|h2|h3|h4|div|p|li|tr)>/g, "\n</$1>")
+ .replace(/<br>/g, "\n")
+ }
+}
+
+class QuoteCommonMarkFormatter {
+ format(selectedRange) {
+ if (!selectedRange) {
+ return null;
+ }
+
+ const htmlFragment = this.extractHtmlFragmentFrom(selectedRange);
+ const preparedHtml = this.prepareHtml(htmlFragment);
+
+ return this.convertHtmlToCommonMark(preparedHtml);
+ }
+
+ extractHtmlFragmentFrom(range) {
+ const fragment = document.createElement('div');
+ const ancestorNodeName = range.commonAncestorContainer.nodeName;
+
+ if (ancestorNodeName == 'CODE' || ancestorNodeName == '#text') {
+ fragment.appendChild(this.wrapPreCode(range));
+ } else {
+ fragment.appendChild(range.cloneContents());
+ }
+
+ return fragment;
+ }
+
+ // When only the content within the `<code>` element is selected,
+ // the HTML within the selection range does not include the `<pre><code>` element itself.
+ // To create a complete code block, wrap the selected content with the `<pre><code>` tags.
+ //
+ // selected contentes => <pre><code class="ruby">selected contents</code></pre>
+ wrapPreCode(range) {
+ const rangeAncestor = range.commonAncestorContainer;
+
+ let codeElement = null;
+
+ if (rangeAncestor.nodeName == 'CODE') {
+ codeElement = rangeAncestor;
+ } else {
+ codeElement = rangeAncestor.parentElement.closest('code');
+ }
+
+ if (!codeElement) {
+ return range.cloneContents();
+ }
+
+ const pre = document.createElement('pre');
+ const code = codeElement.cloneNode(false);
+
+ code.appendChild(range.cloneContents());
+ pre.appendChild(code);
+
+ return pre;
+ }
+
+ convertHtmlToCommonMark(html) {
+ const turndownService = new TurndownService({
+ codeBlockStyle: 'fenced',
+ headingStyle: 'atx'
+ });
+
+ turndownService.addRule('del', {
+ filter: ['del'],
+ replacement: content => `~~${content}~~`
+ });
+
+ turndownService.addRule('checkList', {
+ filter: node => {
+ return node.type === 'checkbox' && node.parentNode.nodeName === 'LI';
+ },
+ replacement: (content, node) => {
+ return node.checked ? '[x]' : '[ ]';
+ }
+ });
+
+ // Table does not maintain its original format,
+ // and the text within the table is displayed as it is
+ //
+ // | A | B | C |
+ // |---|---|---|
+ // | 1 | 2 | 3 |
+ // =>
+ // A B C
+ // 1 2 3
+ turndownService.addRule('table', {
+ filter: ['td', 'th'],
+ replacement: (content, node) => {
+ const separator = node.parentElement.lastElementChild === node ? '' : ' ';
+ return content + separator;
+ }
+ });
+ turndownService.addRule('tableHeading', {
+ filter: ['thead', 'tbody', 'tfoot', 'tr'],
+ replacement: (content, _node) => content
+ });
+ turndownService.addRule('tableRow', {
+ filter: ['tr'],
+ replacement: (content, _node) => {
+ return content + '\n'
+ }
+ });
+
+ return turndownService.turndown(html);
+ }
+
+ prepareHtml(htmlFragment) {
+ // Remove all anchor elements.
+ // <h1>Title1<a href="#Title" class="wiki-anchor">¶</a></h1> => <h1>Title1</h1>
+ htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
+
+ // Convert code highlight blocks to CommonMark format code blocks.
+ // <code class="ruby" data-language="ruby"> => <code class="language-ruby" data-language="ruby">
+ htmlFragment.querySelectorAll('code[data-language]').forEach(e => {
+ e.classList.replace(e.dataset['language'], 'language-' + e.dataset['language'])
+ });
+
+ return htmlFragment.innerHTML;
+ }
+}
+
+export default class extends Controller {
+ static targets = [ 'content' ];
+
+ quote(event) {
+ const { url, textFormatting } = event.params;
+ const selectedRange = QuoteExtractor.extract(this.contentTarget);
+
+ let formatter;
+
+ if (textFormatting === 'common_mark') {
+ formatter = new QuoteCommonMarkFormatter();
+ } else {
+ formatter = new QuoteTextFormatter();
+ }
+
+ post(url, {
+ body: JSON.stringify({ quote: formatter.format(selectedRange) }),
+ contentType: 'application/json',
+ responseKind: 'script'
+ });
+ }
+}
diff --git a/app/javascript/controllers/sticky_issue_header_controller.js b/app/javascript/controllers/sticky_issue_header_controller.js
new file mode 100644
index 000000000..aebc7d2dc
--- /dev/null
+++ b/app/javascript/controllers/sticky_issue_header_controller.js
@@ -0,0 +1,22 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["original", "stickyHeader"];
+
+ connect() {
+ if (!this.originalTarget || !this.stickyHeaderTarget) return;
+
+ this.observer = new IntersectionObserver(
+ ([entry]) => {
+ this.stickyHeaderTarget.classList.toggle("is-visible", !entry.isIntersecting);
+ },
+ { threshold: 0 }
+ );
+
+ this.observer.observe(this.originalTarget);
+ }
+
+ disconnect() {
+ this.observer?.disconnect();
+ }
+}
diff --git a/app/models/comment.rb b/app/models/comment.rb
index 79eb59748..1716537af 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -19,6 +19,8 @@
class Comment < ApplicationRecord
include Redmine::SafeAttributes
+ include Redmine::Reaction::Reactable
+
belongs_to :commented, :polymorphic => true, :counter_cache => true
belongs_to :author, :class_name => 'User'
@@ -28,6 +30,8 @@ class Comment < ApplicationRecord
safe_attributes 'comments'
+ delegate :visible?, to: :commented
+
def comments=(arg)
self.content = arg
end
@@ -36,6 +40,10 @@ class Comment < ApplicationRecord
content
end
+ def project
+ commented.respond_to?(:project) ? commented.project : nil
+ end
+
private
def send_notification
diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb
index ec8c5de8d..d14b67bdb 100644
--- a/app/models/custom_field.rb
+++ b/app/models/custom_field.rb
@@ -101,7 +101,8 @@ class CustomField < ApplicationRecord
'version_status',
'extensions_allowed',
'full_width_layout',
- 'thousands_delimiter'
+ 'thousands_delimiter',
+ 'ratio_interval'
)
def copy_from(arg, options={})
@@ -335,12 +336,12 @@ class CustomField < ApplicationRecord
args.include?(field_format)
end
- def self.human_attribute_name(attribute_key_name, *args)
+ def self.human_attribute_name(attribute_key_name, *)
attr_name = attribute_key_name.to_s
if attr_name == 'url_pattern'
attr_name = "url"
end
- super(attr_name, *args)
+ super(attr_name, *)
end
def css_classes
diff --git a/app/models/email_address.rb b/app/models/email_address.rb
index 69ae8a066..de8c86531 100644
--- a/app/models/email_address.rb
+++ b/app/models/email_address.rb
@@ -74,7 +74,7 @@ class EmailAddress < ApplicationRecord
# Returns true if domain belongs to domains list.
def self.domain_in?(domain, domains)
- domain = domain.downcase
+ domain = domain.to_s.downcase
domains = domains.to_s.split(/[\s,]+/) unless domains.is_a?(Array)
domains.reject(&:blank?).map(&:downcase).any? do |s|
s.start_with?('.') ? domain.end_with?(s) : domain == s
@@ -150,6 +150,10 @@ class EmailAddress < ApplicationRecord
def validate_email_domain
domain = address.partition('@').last
+ # Skip domain validation if the email does not contain a domain part,
+ # to avoid an incomplete error message like "domain not allowed ()"
+ return if domain.empty?
+
return if self.class.valid_domain?(domain)
if User.current.logged?
diff --git a/app/models/group.rb b/app/models/group.rb
index ea5454558..300b59b46 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -94,12 +94,12 @@ class Group < Principal
destroy_all
end
- def self.human_attribute_name(attribute_key_name, *args)
+ def self.human_attribute_name(attribute_key_name, *)
attr_name = attribute_key_name.to_s
if attr_name == 'lastname'
attr_name = "name"
end
- super(attr_name, *args)
+ super(attr_name, *)
end
def self.anonymous
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 980bd56f0..576840843 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -25,6 +25,7 @@ class Issue < ApplicationRecord
before_validation :clear_disabled_fields
before_save :set_parent_id
include Redmine::NestedSet::IssueNestedSet
+ include Redmine::Reaction::Reactable
belongs_to :project
belongs_to :tracker
@@ -188,6 +189,11 @@ class Issue < ApplicationRecord
end
end
+ # Returns true if user or current user is allowed to log time on the issue
+ def time_loggable?(user=User.current)
+ user.allowed_to?(:log_time, project) && (Setting.timelog_accept_closed_issues? || !closed?)
+ end
+
# Returns true if user or current user is allowed to edit or add notes to the issue
def editable?(user=User.current)
attributes_editable?(user) || notes_addable?(user)
@@ -263,7 +269,7 @@ class Issue < ApplicationRecord
end
alias :base_reload :reload
- def reload(*args)
+ def reload(*)
@workflow_rule_by_attribute = nil
@assignable_versions = nil
@relations = nil
@@ -272,7 +278,7 @@ class Issue < ApplicationRecord
@total_estimated_hours = nil
@last_updated_by = nil
@last_notes = nil
- base_reload(*args)
+ base_reload(*)
end
# Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
@@ -464,7 +470,7 @@ class Issue < ApplicationRecord
end
# Overrides assign_attributes so that project and tracker get assigned first
- def assign_attributes(new_attributes, *args)
+ def assign_attributes(new_attributes, *)
return if new_attributes.nil?
attrs = new_attributes.dup
@@ -475,7 +481,7 @@ class Issue < ApplicationRecord
send :"#{attr}=", attrs.delete(attr)
end
end
- super(attrs, *args)
+ super(attrs, *)
end
def attributes=(new_attributes)
@@ -911,7 +917,8 @@ class Issue < ApplicationRecord
result = journals.
preload(:details).
preload(:user => :email_address).
- reorder(:created_on, :id).to_a
+ reorder(:created_on, :id).
+ to_a
result.each_with_index {|j, i| j.indice = i + 1}
@@ -922,6 +929,9 @@ class Issue < ApplicationRecord
end
Journal.preload_journals_details_custom_fields(result)
result.select! {|journal| journal.notes? || journal.visible_details.any?}
+
+ Journal.preload_reaction_details(result)
+
result
end
@@ -1165,7 +1175,7 @@ class Issue < ApplicationRecord
if leaf?
spent_hours
else
- self_and_descendants.joins(:time_entries).sum("#{TimeEntry.table_name}.hours").to_f || 0.0
+ self_and_descendants.joins(:time_entries).sum("#{TimeEntry.table_name}.hours").to_f
end
end
@@ -1198,11 +1208,7 @@ class Issue < ApplicationRecord
end
def last_notes
- if @last_notes
- @last_notes
- else
- journals.visible.where.not(notes: '').reorder(:id => :desc).first.try(:notes)
- end
+ @last_notes || journals.visible.where.not(notes: '').reorder(:id => :desc).first.try(:notes)
end
# Preloads relations for a collection of issues
diff --git a/app/models/issue_relation.rb b/app/models/issue_relation.rb
index e55875e4d..80af22b89 100644
--- a/app/models/issue_relation.rb
+++ b/app/models/issue_relation.rb
@@ -22,9 +22,9 @@ class IssueRelation < ApplicationRecord
class Relations < Array
include Redmine::I18n
- def initialize(issue, *args)
+ def initialize(issue, *)
@issue = issue
- super(*args)
+ super(*)
end
def to_s(*args)
diff --git a/app/models/journal.rb b/app/models/journal.rb
index 179e60c24..12f2beec8 100644
--- a/app/models/journal.rb
+++ b/app/models/journal.rb
@@ -19,6 +19,7 @@
class Journal < ApplicationRecord
include Redmine::SafeAttributes
+ include Redmine::Reaction::Reactable
belongs_to :journalized, :polymorphic => true
# added as a quick fix to allow eager loading of the polymorphic association
@@ -157,8 +158,8 @@ class Journal < ApplicationRecord
end
end
- def visible?(*args)
- journalized.visible?(*args)
+ def visible?(*)
+ journalized.visible?(*)
end
# Returns a string of css classes
diff --git a/app/models/mail_handler.rb b/app/models/mail_handler.rb
index b6858d96a..5d246a572 100644
--- a/app/models/mail_handler.rb
+++ b/app/models/mail_handler.rb
@@ -55,8 +55,8 @@ class MailHandler < ActionMailer::Base
end
# Receives an email and rescues any exception
- def self.safe_receive(*args)
- receive(*args)
+ def self.safe_receive(*)
+ receive(*)
rescue => e
Rails.logger.error "MailHandler: an unexpected error occurred when receiving email: #{e.message}"
return false
diff --git a/app/models/member.rb b/app/models/member.rb
index b0d5c35fc..1f597c96c 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -45,9 +45,9 @@ class Member < ApplicationRecord
end)
alias :base_reload :reload
- def reload(*args)
+ def reload(*)
@managed_roles = nil
- base_reload(*args)
+ base_reload(*)
end
def role
diff --git a/app/models/message.rb b/app/models/message.rb
index c7f78d2d9..9ac88c7d1 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -19,6 +19,8 @@
class Message < ApplicationRecord
include Redmine::SafeAttributes
+ include Redmine::Reaction::Reactable
+
belongs_to :board
belongs_to :author, :class_name => 'User'
acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
diff --git a/app/models/news.rb b/app/models/news.rb
index 40cd63db9..174e4c5ac 100644
--- a/app/models/news.rb
+++ b/app/models/news.rb
@@ -19,6 +19,8 @@
class News < ApplicationRecord
include Redmine::SafeAttributes
+ include Redmine::Reaction::Reactable
+
belongs_to :project
belongs_to :author, :class_name => 'User'
has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all
diff --git a/app/models/principal.rb b/app/models/principal.rb
index 77f599c73..0a6c32ba2 100644
--- a/app/models/principal.rb
+++ b/app/models/principal.rb
@@ -35,6 +35,8 @@ class Principal < ApplicationRecord
:foreign_key => 'user_id'
has_many :projects, :through => :memberships
has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
+ # Always returns nil for groups
+ has_one :email_address, lambda {where :is_default => true}, :autosave => true, :foreign_key => 'user_id'
validate :validate_status
@@ -128,6 +130,11 @@ class Principal < ApplicationRecord
to_s
end
+ # Returns nil by default, subclasses should implement this method
+ def initials(formatter = nil)
+ nil
+ end
+
def mail=(*args)
nil
end
diff --git a/app/models/project.rb b/app/models/project.rb
index c438be16d..b3bf88c94 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -358,12 +358,12 @@ class Project < ApplicationRecord
end
end
- def self.find_by_param(*args)
- self.find(*args)
+ def self.find_by_param(*)
+ self.find(*)
end
alias :base_reload :reload
- def reload(*args)
+ def reload(*)
@principals = nil
@users = nil
@shared_versions = nil
@@ -382,7 +382,7 @@ class Project < ApplicationRecord
@override_members = nil
@assignable_users = nil
@last_activity_date = nil
- base_reload(*args)
+ base_reload(*)
end
def to_param
diff --git a/app/models/reaction.rb b/app/models/reaction.rb
new file mode 100644
index 000000000..184ed2d6e
--- /dev/null
+++ b/app/models/reaction.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+class Reaction < ApplicationRecord
+ belongs_to :reactable, polymorphic: true
+ belongs_to :user
+
+ validates :reactable_type, inclusion: { in: Redmine::Reaction::REACTABLE_TYPES }
+
+ scope :by, ->(user) { where(user: user) }
+ scope :for_reactable, ->(reactable) { where(reactable: reactable) }
+ scope :visible, ->(user) { where(user: User.visible(user)) }
+
+ # Represents reaction details for a reactable object
+ Detail = Struct.new(
+ # Users who reacted and are visible to the target user
+ :visible_users,
+ # Reaction of the target user
+ :user_reaction
+ ) do
+ def initialize(visible_users: [], user_reaction: nil)
+ super
+ end
+
+ def reaction_count = visible_users.size
+ end
+
+ def self.build_detail_map_for(reactables, user)
+ reactions = visible(user)
+ .for_reactable(reactables)
+ .preload(:user)
+ .select(:id, :reactable_id, :user_id)
+ .order(id: :desc)
+
+ reactions.each_with_object({}) do |reaction, m|
+ m[reaction.reactable_id] ||= Detail.new
+
+ m[reaction.reactable_id].then do |detail|
+ detail.visible_users << reaction.user
+ detail.user_reaction = reaction if reaction.user == user
+ end
+ end
+ end
+end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index a1e81baf3..f4092fc96 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -69,12 +69,12 @@ class Repository < ApplicationRecord
end
end
- def self.human_attribute_name(attribute_key_name, *args)
+ def self.human_attribute_name(attribute_key_name, *)
attr_name = attribute_key_name.to_s
if attr_name == "log_encoding"
attr_name = "commit_logs_encoding"
end
- super(attr_name, *args)
+ super(attr_name, *)
end
# Removes leading and trailing whitespace
@@ -369,8 +369,8 @@ class Repository < ApplicationRecord
subclasses.collect {|klass| [klass.scm_name, klass.name]}
end
- def self.factory(klass_name, *args)
- repository_class(klass_name).new(*args) rescue nil
+ def self.factory(klass_name, *)
+ repository_class(klass_name).new(*) rescue nil
end
def self.repository_class(class_name)
diff --git a/app/models/repository/bazaar.rb b/app/models/repository/bazaar.rb
index d9cffe810..fc42c1235 100644
--- a/app/models/repository/bazaar.rb
+++ b/app/models/repository/bazaar.rb
@@ -22,12 +22,12 @@ require 'redmine/scm/adapters/bazaar_adapter'
class Repository::Bazaar < Repository
validates_presence_of :url, :log_encoding
- def self.human_attribute_name(attribute_key_name, *args)
+ def self.human_attribute_name(attribute_key_name, *)
attr_name = attribute_key_name.to_s
if attr_name == "url"
attr_name = "path_to_repository"
end
- super(attr_name, *args)
+ super(attr_name, *)
end
def self.scm_adapter_class
diff --git a/app/models/repository/cvs.rb b/app/models/repository/cvs.rb
index a5fce91bb..d055428a5 100644
--- a/app/models/repository/cvs.rb
+++ b/app/models/repository/cvs.rb
@@ -27,14 +27,14 @@ class Repository::Cvs < Repository
'root_url',
:if => lambda {|repository, user| repository.new_record?})
- def self.human_attribute_name(attribute_key_name, *args)
+ def self.human_attribute_name(attribute_key_name, *)
attr_name = attribute_key_name.to_s
if attr_name == "root_url"
attr_name = "cvsroot"
elsif attr_name == "url"
attr_name = "cvs_module"
end
- super(attr_name, *args)
+ super(attr_name, *)
end
def self.scm_adapter_class
diff --git a/app/models/repository/filesystem.rb b/app/models/repository/filesystem.rb
index 9347de0f3..c27044a9a 100644
--- a/app/models/repository/filesystem.rb
+++ b/app/models/repository/filesystem.rb
@@ -25,12 +25,12 @@ require 'redmine/scm/adapters/filesystem_adapter'
class Repository::Filesystem < Repository
validates_presence_of :url
- def self.human_attribute_name(attribute_key_name, *args)
+ def self.human_attribute_name(attribute_key_name, *)
attr_name = attribute_key_name.to_s
if attr_name == "url"
attr_name = "root_directory"
end
- super(attr_name, *args)
+ super(attr_name, *)
end
def self.scm_adapter_class
diff --git a/app/models/repository/git.rb b/app/models/repository/git.rb
index b6b3c8336..c94acb01d 100644
--- a/app/models/repository/git.rb
+++ b/app/models/repository/git.rb
@@ -25,10 +25,10 @@ class Repository::Git < Repository
safe_attributes 'report_last_commit'
- def self.human_attribute_name(attribute_key_name, *args)
+ def self.human_attribute_name(attribute_key_name, *)
attr_name = attribute_key_name.to_s
attr_name = 'path_to_repository' if attr_name == 'url'
- super(attr_name, *args)
+ super(attr_name, *)
end
def self.scm_adapter_class
diff --git a/app/models/repository/mercurial.rb b/app/models/repository/mercurial.rb
index 8794cde75..1d1a3c4ff 100644
--- a/app/models/repository/mercurial.rb
+++ b/app/models/repository/mercurial.rb
@@ -30,12 +30,12 @@ class Repository::Mercurial < Repository
# number of changesets to fetch at once
FETCH_AT_ONCE = 100
- def self.human_attribute_name(attribute_key_name, *args)
+ def self.human_attribute_name(attribute_key_name, *)
attr_name = attribute_key_name.to_s
if attr_name == "url"
attr_name = "path_to_repository"
end
- super(attr_name, *args)
+ super(attr_name, *)
end
def self.scm_adapter_class
diff --git a/app/models/role.rb b/app/models/role.rb
index 3ca4f92a1..870bbe945 100644
--- a/app/models/role.rb
+++ b/app/models/role.rb
@@ -198,11 +198,14 @@ class Role < ApplicationRecord
# action can be:
# * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
# * a permission Symbol (eg. :edit_project)
- def allowed_to?(action)
+ # scope can be:
+ # * an array of permissions which will be used as filter (logical AND)
+
+ def allowed_to?(action, scope=nil)
if action.is_a? Hash
- allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
+ allowed_actions(scope).include? "#{action[:controller]}/#{action[:action]}"
else
- allowed_permissions.include? action
+ allowed_permissions(scope).include? action
end
end
@@ -298,13 +301,20 @@ class Role < ApplicationRecord
private
- def allowed_permissions
- @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
+ def allowed_permissions(scope = nil)
+ scope = scope.sort if scope.present? # to maintain stable cache keys
+ @allowed_permissions ||= {}
+ @allowed_permissions[scope] ||= begin
+ unscoped = permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
+ scope.present? ? unscoped & scope : unscoped
+ end
end
- def allowed_actions
- @actions_allowed ||=
- allowed_permissions.inject([]) do |actions, permission|
+ def allowed_actions(scope = nil)
+ scope = scope.sort if scope.present? # to maintain stable cache keys
+ @actions_allowed ||= {}
+ @actions_allowed[scope] ||=
+ allowed_permissions(scope).inject([]) do |actions, permission|
actions += Redmine::AccessControl.allowed_actions(permission)
end.flatten
end
diff --git a/app/models/time_entry.rb b/app/models/time_entry.rb
index abbbe4441..f20958894 100644
--- a/app/models/time_entry.rb
+++ b/app/models/time_entry.rb
@@ -182,6 +182,9 @@ class TimeEntry < ApplicationRecord
if spent_on && spent_on_changed? && user
errors.add :base, I18n.t(:error_spent_on_future_date) if !Setting.timelog_accept_future_dates? && (spent_on > user.today)
end
+ if !Setting.timelog_accept_closed_issues? && issue&.closed? && issue.was_closed?
+ errors.add :base, I18n.t(:error_spent_on_closed_issue)
+ end
end
def hours=(h)
@@ -240,8 +243,11 @@ class TimeEntry < ApplicationRecord
def assignable_users
users = []
if project
- users = project.members.active.preload(:user)
- users = users.map(&:user).select{|u| u.allowed_to?(:log_time, project)}
+ user_ids =
+ project.members.active.preload(:roles).filter_map do |m|
+ m.roles.any? {|role| role.allowed_to?(:log_time)} ? m.user_id : nil
+ end.uniq
+ users = User.where(:id => user_ids).sorted.to_a
end
users << User.current if User.current.logged? && !users.include?(User.current)
users
diff --git a/app/models/time_entry_query.rb b/app/models/time_entry_query.rb
index 41997180b..82b895671 100644
--- a/app/models/time_entry_query.rb
+++ b/app/models/time_entry_query.rb
@@ -164,12 +164,19 @@ class TimeEntryQuery < Query
end
def base_scope
- TimeEntry.visible.
- joins(:project, :user).
- includes(:activity).
- references(:activity).
- left_join_issue.
- where(statement)
+ scope = TimeEntry.visible
+ .joins(:project, :user)
+ .includes(:activity)
+ .references(:activity)
+ .left_join_issue
+ .where(statement)
+
+ if Redmine::Database.mysql? && ActiveRecord::Base.connection.supports_optimizer_hints?
+ # Provides MySQL with a hint to use a better join order and avoid slow response times
+ scope.optimizer_hints('JOIN_ORDER(time_entries, projects, users)')
+ else
+ scope
+ end
end
def results_scope(options={})
diff --git a/app/models/user.rb b/app/models/user.rb
index 4ce63f809..496084ceb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -28,46 +28,55 @@ class User < Principal
USER_FORMATS = {
:firstname_lastname => {
:string => '#{firstname} #{lastname}',
+ :initials => '#{firstname.to_s.first}#{lastname.to_s.first}',
:order => %w(firstname lastname id),
:setting_order => 1
},
:firstname_lastinitial => {
:string => '#{firstname} #{lastname.to_s.chars.first}.',
+ :initials => '#{firstname.to_s.first}#{lastname.to_s.first}',
:order => %w(firstname lastname id),
:setting_order => 2
},
:firstinitial_lastname => {
:string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
+ :initials => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\').first}#{lastname.to_s.first}',
:order => %w(firstname lastname id),
:setting_order => 2
},
:firstname => {
:string => '#{firstname}',
+ :initials => '#{firstname.to_s.first(2)}',
:order => %w(firstname id),
:setting_order => 3
},
:lastname_firstname => {
:string => '#{lastname} #{firstname}',
+ :initials => '#{lastname.to_s.first}#{firstname.to_s.first}',
:order => %w(lastname firstname id),
:setting_order => 4
},
:lastnamefirstname => {
:string => '#{lastname}#{firstname}',
+ :initials => '#{lastname.to_s.first}#{firstname.to_s.first}',
:order => %w(lastname firstname id),
:setting_order => 5
},
:lastname_comma_firstname => {
:string => '#{lastname}, #{firstname}',
+ :initials => '#{lastname.to_s.first}#{firstname.to_s.first}',
:order => %w(lastname firstname id),
:setting_order => 6
},
:lastname => {
:string => '#{lastname}',
+ :initials => '#{lastname.to_s.first(2)}',
:order => %w(lastname id),
:setting_order => 7
},
:username => {
:string => '#{login}',
+ :initials => '#{login.to_s.first(2)}',
:order => %w(login id),
:setting_order => 8
},
@@ -89,10 +98,10 @@ class User < Principal
:after_remove => Proc.new {|user, group| group.user_removed(user)}
has_many :changesets, :dependent => :nullify
has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
- has_one :atom_token, lambda {where "action='feeds'"}, :class_name => 'Token'
- has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
- has_one :email_address, lambda {where :is_default => true}, :autosave => true
+ has_one :atom_token, lambda {where "#{table.name}.action='feeds'"}, :class_name => 'Token'
+ has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token'
has_many :email_addresses, :dependent => :delete_all
+ has_many :reactions, dependent: :delete_all
belongs_to :auth_source
scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")}
@@ -103,6 +112,7 @@ class User < Principal
attr_accessor :password, :password_confirmation, :generate_password
attr_accessor :last_before_login_on
attr_accessor :remote_ip
+ attr_writer :oauth_scope
LOGIN_LENGTH_LIMIT = 60
MAIL_LENGTH_LIMIT = 254
@@ -170,7 +180,7 @@ class User < Principal
end
alias :base_reload :reload
- def reload(*args)
+ def reload(*)
@name = nil
@roles = nil
@projects_by_role = nil
@@ -181,7 +191,7 @@ class User < Principal
@builtin_role = nil
@visible_project_ids = nil
@managed_roles = nil
- base_reload(*args)
+ base_reload(*)
end
def mail
@@ -275,6 +285,14 @@ class User < Principal
end
end
+ # Return user's initials based on name format
+ def initials(formatter = nil)
+ f = self.class.name_formatter(formatter)
+ format = f[:initials] || USER_FORMATS[:firstname_lastname][:initials]
+ initials = eval('"' + format + '"')
+ initials.upcase
+ end
+
def registered?
self.status == STATUS_REGISTERED
end
@@ -643,7 +661,7 @@ class User < Principal
def projects_by_role
return @projects_by_role if @projects_by_role
- result = Hash.new([])
+ result = Hash.new {|_h, _k| []}
project_ids_by_role.each do |role, ids|
result[role] = Project.where(:id => ids).to_a
end
@@ -676,7 +694,7 @@ class User < Principal
hash[role_id] << project_id
end
- result = Hash.new([])
+ result = Hash.new {|_h, _k| []}
if hash.present?
roles = Role.where(:id => hash.keys).to_a
hash.each do |role_id, proj_ids|
@@ -715,6 +733,20 @@ class User < Principal
end
end
+ def admin?
+ if authorized_by_oauth?
+ # when signed in via oauth, the user only acts as admin when the admin scope is set
+ super and @oauth_scope.include?(:admin)
+ else
+ super
+ end
+ end
+
+ # true if the user has signed in via oauth
+ def authorized_by_oauth?
+ !@oauth_scope.nil?
+ end
+
# Return true if the user is allowed to do the specified action on a specific context
# Action can be:
# * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
@@ -735,7 +767,7 @@ class User < Principal
roles.any? do |role|
(context.is_public? || role.member?) &&
- role.allowed_to?(action) &&
+ role.allowed_to?(action, @oauth_scope) &&
(block ? yield(role, self) : true)
end
elsif context && context.is_a?(Array)
@@ -754,7 +786,7 @@ class User < Principal
# authorize if user has at least one role that has this permission
roles = self.roles.to_a | [builtin_role]
roles.any? do |role|
- role.allowed_to?(action) &&
+ role.allowed_to?(action, @oauth_scope) &&
(block ? yield(role, self) : true)
end
else
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 8b19d9a5a..e1842b131 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -73,7 +73,7 @@ class UserPreference < ApplicationRecord
if has_attribute? attr_name
super
else
- others ? others[attr_name] : nil
+ others&.[](attr_name)
end
end
diff --git a/app/models/version.rb b/app/models/version.rb
index 51c7c0417..3ca4f2bff 100644
--- a/app/models/version.rb
+++ b/app/models/version.rb
@@ -106,7 +106,7 @@ module FixedIssuesExtension
done = self.open(open).sum do |c|
estimated = c.total_estimated_hours.to_f
estimated = estimated_average unless estimated > 0.0
- ratio = c.closed? ? 100 : (c.done_ratio || 0)
+ ratio = open ? (c.done_ratio || 0) : 100
estimated * ratio
end
progress = done / (estimated_average * issues_count)
@@ -211,8 +211,8 @@ class Version < ApplicationRecord
end
# Version files have same visibility as project files
- def attachments_visible?(*args)
- project.present? && project.attachments_visible?(*args)
+ def attachments_visible?(*)
+ project.present? && project.attachments_visible?(*)
end
def attachments_deletable?(usr=User.current)
@@ -220,10 +220,10 @@ class Version < ApplicationRecord
end
alias :base_reload :reload
- def reload(*args)
+ def reload(*)
@default_project_version = nil
@visible_fixed_issues = nil
- base_reload(*args)
+ base_reload(*)
end
def start_date
diff --git a/app/views/activities/_activities.html.erb b/app/views/activities/_activities.html.erb
index 21ec1fb28..f2d8e22bd 100644
--- a/app/views/activities/_activities.html.erb
+++ b/app/views/activities/_activities.html.erb
@@ -4,7 +4,7 @@
<dl>
<% sort_activity_events(events_by_day[day]).each do |e, in_group| -%>
<dt class="<%= e.event_type %> icon icon-<%= e.event_type %> <%= "grouped" if in_group %> <%= User.current.logged? && e.respond_to?(:event_author) && User.current == e.event_author ? 'me' : nil %>">
- <%= activity_event_type_icon e.event_type, plugin: Redmine::Activity.plugin_name(e.activity_provider_options.keys[0]) %>
+ <%= activity_event_type_icon e.event_type, plugin: Redmine::Activity.plugin_name(e.class) %>
<%= avatar(e.event_author) if e.respond_to?(:event_author) %>
<span class="time"><%= format_time(e.event_datetime, false) %></span>
<%= content_tag('span', e.project, :class => 'project') if @project.nil? || @project != e.project %>
diff --git a/app/views/attachments/_form.html.erb b/app/views/attachments/_form.html.erb
index 7bda59c95..e5b10fb55 100644
--- a/app/views/attachments/_form.html.erb
+++ b/app/views/attachments/_form.html.erb
@@ -7,17 +7,22 @@
<% css_class = (defined?(filedrop) && filedrop == false ? '' : (attachment_format_custom_field ? 'custom-field-filedrop' : 'filedrop')) %>
<span class="attachments_form">
+ <span class="attachments_icons hidden">
+ <%= sprite_icon('del', icon_only: true, css_class: 'svg-del') %>
+ <%= sprite_icon('attachment', icon_only: true, size: 16, css_class: 'svg-attachment') %>
+ </span>
<span class="attachments_fields">
<% if saved_attachments.present? %>
<% saved_attachments.each_with_index do |attachment, i| %>
<span id="attachments_p<%= i %>">
+ <%= sprite_icon('attachment', icon_only: true, size: 16, css_class: 'svg-attachment') %>
<%= text_field_tag("#{attachment_param}[p#{i}][filename]", attachment.filename, :class => 'filename') %>
<% if attachment.container_id.present? %>
- <%= link_to l(:label_delete), "#", :onclick => "$(this).closest('.attachments_form').find('.add_attachment').show(); $(this).parent().remove(); return false;", :class => 'icon-only icon-del' %>
+ <%= link_to sprite_icon('del', l(:button_delete), icon_only: true), "#", :onclick => "$(this).closest('.attachments_form').find('.add_attachment').show(); $(this).parent().remove(); return false;", :class => 'icon-only icon-del' %>
<%= hidden_field_tag "#{attachment_param}[p#{i}][id]", attachment.id %>
<% else %>
<%= text_field_tag("#{attachment_param}[p#{i}][description]", attachment.description, :maxlength => 255, :placeholder => l(:label_optional_description), :class => 'description') if description %>
- <%= link_to('&nbsp;'.html_safe, attachment_path(attachment, :attachment_id => "p#{i}", :format => 'js'), :method => 'delete', :remote => true, :class => 'icon-only icon-del remove-upload') %>
+ <%= link_to(sprite_icon('del', l(:button_delete), icon_only: true), attachment_path(attachment, :attachment_id => "p#{i}", :format => 'js'), :method => 'delete', :remote => true, :class => 'icon-only icon-del remove-upload') %>
<%= hidden_field_tag "#{attachment_param}[p#{i}][token]", attachment.token %>
<% end %>
</span>
diff --git a/app/views/attachments/other.html.erb b/app/views/attachments/other.html.erb
index f0f732f6f..613c470df 100644
--- a/app/views/attachments/other.html.erb
+++ b/app/views/attachments/other.html.erb
@@ -14,6 +14,7 @@
:download_link => link_to_attachment(
@attachment,
:text => l(:label_no_preview_download),
+ :icon => 'download',
:download => true,
:class => 'icon icon-download'
)
diff --git a/app/views/calendars/show.html.erb b/app/views/calendars/show.html.erb
index c1d412a0b..d5cb6a6a1 100644
--- a/app/views/calendars/show.html.erb
+++ b/app/views/calendars/show.html.erb
@@ -10,7 +10,7 @@
<div id="query_form_content">
<fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
<legend onclick="toggleFieldset(this);" class="icon icon-<%= @query.new_record? ? "expanded" : "collapsed" %>">
- <%= sprite_icon(@query.new_record? ? "angle-down" : "angle-right") %>
+ <%= sprite_icon(@query.new_record? ? "angle-down" : "angle-right", rtl: !@query.new_record?) %>
<%= l(:label_filter_plural) %>
</legend>
<div style="<%= @query.new_record? ? "" : "display: none;" %>">
diff --git a/app/views/common/_tabs.html.erb b/app/views/common/_tabs.html.erb
index def21ff45..0190e5e6b 100644
--- a/app/views/common/_tabs.html.erb
+++ b/app/views/common/_tabs.html.erb
@@ -12,8 +12,12 @@
<% end -%>
</ul>
<div class="tabs-buttons" style="display:none;">
- <button class="tab-left" type="button" onclick="moveTabLeft(this);"></button>
- <button class="tab-right" type="button" onclick="moveTabRight(this);"></button>
+ <button class="tab-left icon-only" type="button" onclick="moveTabLeft(this);">
+ <%= sprite_icon "angle-left" %>
+ </button>
+ <button class="tab-right icon-only" type="button" onclick="moveTabRight(this);">
+ <%= sprite_icon "angle-right" %>
+ </button>
</div>
</div>
diff --git a/app/views/common/error.html.erb b/app/views/common/error.html.erb
index 007063f9c..e1c6ef82f 100644
--- a/app/views/common/error.html.erb
+++ b/app/views/common/error.html.erb
@@ -1,7 +1,10 @@
<h2><%= @status %></h2>
<% if @message.present? %>
- <p id="errorExplanation"><%= @message %></p>
+ <p id="errorExplanation">
+ <%= notice_icon('error') %>
+ <%= @message %>
+ </p>
<% end %>
<% if @archived_project && User.current.admin? %>
diff --git a/app/views/context_menus/issues.html.erb b/app/views/context_menus/issues.html.erb
index 64603dc61..ee7eaa18c 100644
--- a/app/views/context_menus/issues.html.erb
+++ b/app/views/context_menus/issues.html.erb
@@ -12,6 +12,7 @@
<% if @allowed_statuses.present? %>
<li class="folder">
<a href="#" class="submenu"><%= l(:field_status) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% @allowed_statuses.each do |s| -%>
<li>
@@ -33,6 +34,7 @@
<% if @trackers.present? %>
<li class="folder">
<a href="#" class="submenu"><%= l(:field_tracker) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% @trackers.each do |t| -%>
<li><%= context_menu_link t.name, _bulk_update_issues_path(@issue, :ids => @issue_ids, :issue => {'tracker_id' => t}, :back_url => @back), :method => :patch,
@@ -45,6 +47,7 @@
<% if @safe_attributes.include?('priority_id') && @priorities.present? -%>
<li class="folder">
<a href="#" class="submenu"><%= l(:field_priority) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% @priorities.each do |p| -%>
<li><%= context_menu_link p.name, _bulk_update_issues_path(@issue, :ids => @issue_ids, :issue => {'priority_id' => p}, :back_url => @back), :method => :patch,
@@ -57,6 +60,7 @@
<% if @safe_attributes.include?('fixed_version_id') && @versions.present? -%>
<li class="folder">
<a href="#" class="submenu"><%= l(:field_fixed_version) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% @versions.sort.each do |v| -%>
<li><%= context_menu_link format_version_name(v), _bulk_update_issues_path(@issue, :ids => @issue_ids, :issue => {'fixed_version_id' => v}, :back_url => @back), :method => :patch,
@@ -71,6 +75,7 @@
<% if @safe_attributes.include?('assigned_to_id') && @assignables.present? -%>
<li class="folder">
<a href="#" class="submenu"><%= l(:field_assigned_to) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% if @assignables.include?(User.current) %>
<li><%= context_menu_link "<< #{l(:label_me)} >>", _bulk_update_issues_path(@issue, :ids => @issue_ids, :issue => {'assigned_to_id' => User.current}, :back_url => @back), :method => :patch,
@@ -89,6 +94,7 @@
<% if @safe_attributes.include?('category_id') && @project && @project.issue_categories.any? -%>
<li class="folder">
<a href="#" class="submenu"><%= l(:field_category) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% @project.issue_categories.each do |u| -%>
<li><%= context_menu_link u.name, _bulk_update_issues_path(@issue, :ids => @issue_ids, :issue => {'category_id' => u}, :back_url => @back), :method => :patch,
@@ -103,6 +109,7 @@
<% if @safe_attributes.include?('done_ratio') && Issue.use_field_for_done_ratio? %>
<li class="folder">
<a href="#" class="submenu"><%= l(:field_done_ratio) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% (0..10).map{|x|x*10}.each do |p| -%>
<li><%= context_menu_link "#{p}%", _bulk_update_issues_path(@issue, :ids => @issue_ids, :issue => {'done_ratio' => p}, :back_url => @back), :method => :patch,
@@ -115,6 +122,7 @@
<% @options_by_custom_field.each do |field, options| %>
<li class="folder <%= field.css_classes %>">
<a href="#" class="submenu"><%= field.name %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% options.each do |text, value| %>
<li><%= bulk_update_custom_field_context_menu_link(field, text, value || text) %></li>
@@ -129,6 +137,7 @@
<% if @can[:add_watchers] %>
<li class="folder">
<a href="#" class="submenu"><%= l(:label_issue_watchers) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<li><%= context_menu_link sprite_icon('add', l(:button_add)),
new_watchers_path(:object_type => 'issue', :object_id => @issue_ids),
diff --git a/app/views/context_menus/time_entries.html.erb b/app/views/context_menus/time_entries.html.erb
index c350aa337..d43021f59 100644
--- a/app/views/context_menus/time_entries.html.erb
+++ b/app/views/context_menus/time_entries.html.erb
@@ -12,6 +12,7 @@
<% if @activities.present? -%>
<li class="folder">
<a href="#" class="submenu"><%= l(:field_activity) %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% @activities.each do |u| -%>
<li><%= context_menu_link u.name, {:controller => 'timelog', :action => 'bulk_update', :ids => @time_entries.collect(&:id), :time_entry => {'activity_id' => u}, :back_url => @back}, :method => :post,
@@ -24,6 +25,7 @@
<% @options_by_custom_field.each do |field, options| %>
<li class="folder <%= field.css_classes %>">
<a href="#" class="submenu"><%= field.name %></a>
+ <span class="icon-only"><%= sprite_icon('angle-right', rtl: true) %></span>
<ul>
<% options.each do |text, value| %>
<li><%= bulk_update_time_entry_custom_field_context_menu_link(field, text, value || text) %></li>
diff --git a/app/views/custom_fields/formats/_progressbar.html.erb b/app/views/custom_fields/formats/_progressbar.html.erb
new file mode 100644
index 000000000..ceae14ac8
--- /dev/null
+++ b/app/views/custom_fields/formats/_progressbar.html.erb
@@ -0,0 +1,6 @@
+<p>
+ <%= f.select :ratio_interval,
+ [5, 10].collect {|i| ["#{i} %", i]},
+ selected: f.object.new_record? ? Redmine::FieldFormat::ProgressbarFormat.default_ratio_interval : f.object.ratio_interval,
+ required: true %>
+</p>
diff --git a/app/views/custom_fields/index.api.rsb b/app/views/custom_fields/index.api.rsb
index 9f46f89f2..d4b19d62b 100644
--- a/app/views/custom_fields/index.api.rsb
+++ b/app/views/custom_fields/index.api.rsb
@@ -15,6 +15,7 @@ api.array :custom_fields do
api.multiple field.multiple?
api.default_value field.default_value
api.visible field.visible?
+ api.editable field.editable?
values = field.possible_values_options
if values.present?
diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb
new file mode 100644
index 000000000..e4f778f63
--- /dev/null
+++ b/app/views/doorkeeper/applications/_form.html.erb
@@ -0,0 +1,39 @@
+<%= error_messages_for 'application' %>
+<div class="box tabular">
+ <p><%= f.text_field :name, :required => true %></p>
+
+ <p>
+ <%= f.text_area :redirect_uri, :required => true, :size => 60, :label => :'activerecord.attributes.doorkeeper/application.redirect_uri' %>
+ <em class="info">
+ <%= t('doorkeeper.applications.help.redirect_uri') %>
+ </em>
+ </p>
+</div>
+
+<h3><%= l(:'activerecord.attributes.doorkeeper/application.scopes') %></h3>
+<p><em class="info"><%= l :text_oauth_info_scopes %></em></p>
+<div class="box tabular" id="scopes">
+<fieldset><legend><%= l :label_oauth_admin_access %></legend>
+ <label class="floating" style="width: auto;">
+ <%= check_box_tag 'doorkeeper_application[scopes][]', 'admin', @application.scopes.include?('admin'),
+ :id => "doorkeeper_application_scopes_admin"
+ %>
+ <%= l :text_oauth_admin_permission %>
+ </label>
+</fieldset>
+<% perms_by_module = Redmine::AccessControl.permissions.group_by {|p| p.project_module.to_s} %>
+<% perms_by_module.keys.sort.each do |mod| %>
+ <fieldset><legend><%= mod.blank? ? l(:label_project) : l_or_humanize(mod, :prefix => 'project_module_') %></legend>
+ <% perms_by_module[mod].each do |permission| %>
+ <label class="floating">
+ <%= check_box_tag 'doorkeeper_application[scopes][]', permission.name.to_s, (permission.public? || @application.scopes.include?( permission.name.to_s)),
+ :id => "doorkeeper_application_scopes_#{permission.name}",
+ :disabled => permission.public? %>
+ <%= l_or_humanize(permission.name, :prefix => 'permission_') %>
+ </label>
+ <% end %>
+ </fieldset>
+<% end %>
+<br /><%= check_all_links 'scopes' %>
+<%= hidden_field_tag 'doorkeeper_application[scopes][]', '' %>
+</div>
diff --git a/app/views/doorkeeper/applications/edit.html.erb b/app/views/doorkeeper/applications/edit.html.erb
new file mode 100644
index 000000000..aebc1a841
--- /dev/null
+++ b/app/views/doorkeeper/applications/edit.html.erb
@@ -0,0 +1,6 @@
+<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %>
+
+<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %>
+ <%= render :partial => 'form', :locals => {:f => f} %>
+ <%= submit_tag l(:button_save) %>
+<% end %>
diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb
new file mode 100644
index 000000000..0ba31c0e8
--- /dev/null
+++ b/app/views/doorkeeper/applications/index.html.erb
@@ -0,0 +1,33 @@
+<div class="contextual">
+<%= link_to sprite_icon('add', t('.new')), new_oauth_application_path, :class => 'icon icon-add' %>
+</div>
+
+<%= title l 'label_oauth_application_plural' %>
+
+<% if @applications.any? %>
+<div class="autoscroll">
+<table class="list">
+ <thead><tr>
+ <th><%= t('.name') %></th>
+ <th><%= t('.callback_url') %></th>
+ <th><%= t('.scopes') %></th>
+ <th></th>
+ </tr></thead>
+ <tbody>
+ <% @applications.each do |application| %>
+ <tr id="application_<%= application.id %>" class="<%= cycle("odd", "even") %>">
+ <td class="name"><span><%= link_to application.name, oauth_application_path(application) %></span></td>
+ <td class="description"><%= truncate application.redirect_uri.split.join(', '), length: 50 %></td>
+ <td class="description"><%= safe_join application.scopes.map{|scope| h l_or_humanize(scope, prefix: 'permission_')}, ", " %></td>
+ <td class="buttons">
+ <%= link_to sprite_icon('edit', t('doorkeeper.applications.buttons.edit')), edit_oauth_application_path(application), class: 'icon icon-edit' %>
+ <%= link_to sprite_icon('del', t('doorkeeper.applications.buttons.destroy')), oauth_application_path(application), :data => {:confirm => t('doorkeeper.applications.confirmations.destroy')}, :method => :delete, :class => 'icon icon-del' %>
+ </td>
+ </tr>
+ <% end %>
+ </tbody>
+</table>
+</div>
+<% else %>
+ <p class="nodata"><%= l(:label_no_data) %></p>
+<% end %>
diff --git a/app/views/doorkeeper/applications/new.html.erb b/app/views/doorkeeper/applications/new.html.erb
new file mode 100644
index 000000000..e2a39ac93
--- /dev/null
+++ b/app/views/doorkeeper/applications/new.html.erb
@@ -0,0 +1,6 @@
+<%= title [l('label_oauth_application_plural'), oauth_applications_path], t('.title') %>
+
+<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %>
+<%= render :partial => 'form', :locals => { :f => f } %>
+<%= submit_tag l(:button_create) %>
+<% end %>
diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb
new file mode 100644
index 000000000..c98e7d29c
--- /dev/null
+++ b/app/views/doorkeeper/applications/show.html.erb
@@ -0,0 +1,54 @@
+<div class="contextual">
+<%= link_to sprite_icon('edit', t('doorkeeper.applications.buttons.edit')), edit_oauth_application_path(@application), :accesskey => accesskey(:edit), class: 'icon icon-edit' %>
+<%= link_to sprite_icon('del', t('doorkeeper.applications.buttons.destroy')), oauth_application_path(@application), :data => {:confirm => t('doorkeeper.applications.confirmations.destroy')}, :method => :delete, :class => 'icon icon-del' %>
+</div>
+
+<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %>
+
+<div class="box">
+ <h3 class="icon icon-passwd"><%= sprite_icon('key', l(:label_information_plural)) %></h3>
+ <p>
+ <span class="label"><%= t('.application_id') %>:</span>
+ <code><%= h @application.uid %></code>
+ </p>
+ <p>
+ <span class="label"><%= t('.secret') %>:</span>
+ <code>
+ <% secret = flash[:application_secret].presence || @application.plaintext_secret %>
+ <% flash.delete :application_secret %>
+ <% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>
+ <%= t('.secret_hashed') %>
+ <% else %>
+ <%= secret %>
+ <% end %>
+ </code>
+ <% if secret.present? && Doorkeeper.config.application_secret_hashed? %>
+ <strong><%= t "text_oauth_copy_secret_now" %></strong>
+ <% end %>
+ </p>
+ <p>
+ <span class="label"><%= t('.scopes') %>:</span>
+ <code><%= safe_join @application.scopes.map{|scope| h l_or_humanize(scope, prefix: 'permission_')}, ", " %></code>
+ </p>
+</div>
+
+<h3><%= t('.callback_urls') %></h3>
+
+<div class="autoscroll">
+<table class="list">
+ <thead><tr>
+ <th><%= t('.callback_url') %></th>
+ <th></th>
+ </tr></thead>
+ <tbody>
+ <% @application.redirect_uri.split.each do |uri| %>
+ <tr class="<%= cycle("odd", "even") %>">
+ <td class="name"><span><%= uri %></span></td>
+ <td class="buttons">
+ <%= link_to sprite_icon('shield-check', t('doorkeeper.applications.buttons.authorize')), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: 'code', scope: @application.scopes), class: 'icon icon-authorize', target: '_blank' %>
+ </td>
+ </tr>
+ <% end %>
+ </tbody>
+</table>
+</div>
diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb
new file mode 100644
index 000000000..59cedf8f3
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/error.html.erb
@@ -0,0 +1,6 @@
+<h2><%= t('doorkeeper.authorizations.error.title') %></h2>
+
+<p id="errorExplanation"><%= @pre_auth.error_response.body[:error_description] %></p>
+<p><a href="javascript:history.back()"><%= l(:button_back) %></a></p>
+
+<% html_title t('doorkeeper.authorizations.error.title') %>
diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb
new file mode 100644
index 000000000..898f2e645
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/new.html.erb
@@ -0,0 +1,48 @@
+<%= title t('.title') %>
+
+<div class="warning">
+<p><strong><%=h @pre_auth.client.name %></strong></p>
+
+<p><%= raw t('.prompt', client_name: content_tag(:strong, class: "text-info") { @pre_auth.client.name }) %></p>
+
+<div class="oauth-permissions">
+ <p><%= t('.able_to') %>:</p>
+ <ul>
+ <li><%= l :text_oauth_implicit_permissions %></li>
+ <% @pre_auth.scopes.each do |scope| %>
+ <% if scope == 'admin' %>
+ <li><%= l :label_oauth_permission_admin %></li>
+ <% else %>
+ <li><%= l_or_humanize(scope, prefix: 'permission_') %></li>
+ <% end %>
+ <% end %>
+ </ul>
+</div>
+
+<% if @pre_auth.scopes.include?('admin') %>
+ <p><%= l :text_oauth_admin_permission_info %></p>
+<% end %>
+</div>
+
+<p>
+ <%= form_tag oauth_authorization_path, method: :post do %>
+ <%= hidden_field_tag :client_id, @pre_auth.client.uid %>
+ <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %>
+ <%= hidden_field_tag :state, @pre_auth.state %>
+ <%= hidden_field_tag :response_type, @pre_auth.response_type %>
+ <%= hidden_field_tag :scope, @pre_auth.scope %>
+ <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge %>
+ <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method %>
+ <%= submit_tag t('doorkeeper.authorizations.buttons.authorize') %>
+ <% end %>
+ <%= form_tag oauth_authorization_path, method: :delete do %>
+ <%= hidden_field_tag :client_id, @pre_auth.client.uid %>
+ <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %>
+ <%= hidden_field_tag :state, @pre_auth.state %>
+ <%= hidden_field_tag :response_type, @pre_auth.response_type %>
+ <%= hidden_field_tag :scope, @pre_auth.scope %>
+ <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge %>
+ <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method %>
+ <%= submit_tag t('doorkeeper.authorizations.buttons.deny') %>
+ <% end %>
+</p>
diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb
new file mode 100644
index 000000000..25ee88a87
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/show.html.erb
@@ -0,0 +1,8 @@
+<%= title [l('label_oauth_authorized_application_plural'), oauth_authorized_applications_path] %>
+
+<fieldset class="tabular"><legend><%= l(:label_information_plural) %></legend>
+ <p>
+ <label><%= t('.title') %>:</label>
+ <code><%= params[:code] %></code>
+ </p>
+</fieldset>
diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb
new file mode 100644
index 000000000..0a1fc8a00
--- /dev/null
+++ b/app/views/doorkeeper/authorized_applications/index.html.erb
@@ -0,0 +1,31 @@
+<%= title [t(:label_my_account), my_account_path], l('label_oauth_authorized_application_plural') %>
+
+<% if @applications.any? %>
+<div class="autoscroll">
+<table class="list">
+ <thead><tr>
+ <th><%= t('doorkeeper.authorized_applications.index.application') %></th>
+ <th><%= t('doorkeeper.authorized_applications.index.created_at') %></th>
+ <th></th>
+ </tr></thead>
+ <tbody>
+ <% @applications.each do |application| %>
+ <tr id="application_<%= application.id %>" class="<%= cycle("odd", "even") %>">
+ <td class="name"><span><%= application.name %></span></td>
+ <td ><%= format_date application.created_at %></td>
+ <td class="buttons">
+ <%= link_to sprite_icon('del', t('doorkeeper.authorized_applications.buttons.revoke')), oauth_authorized_application_path(application), :data => {:confirm => t('doorkeeper.authorized_applications.confirmations.revoke')}, :method => :delete, :class => 'icon icon-del' %>
+ </td>
+ </tr>
+ <% end %>
+ </tbody>
+</table>
+</div>
+<% else %>
+ <p class="nodata"><%= l(:label_no_data) %></p>
+<% end %>
+
+<% content_for :sidebar do %>
+<% @user = User.current %>
+<%= render :partial => 'my/sidebar' %>
+<% end %>
diff --git a/app/views/gantts/show.html.erb b/app/views/gantts/show.html.erb
index c43f10fdd..45428b03d 100644
--- a/app/views/gantts/show.html.erb
+++ b/app/views/gantts/show.html.erb
@@ -16,7 +16,7 @@
<div id="query_form_content">
<fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
<legend onclick="toggleFieldset(this);" class="icon icon-<%= @query.new_record? ? "expanded" : "collapsed" %>">
- <%= sprite_icon(@query.new_record? ? "angle-down" : "angle-right") %>
+ <%= sprite_icon(@query.new_record? ? "angle-down" : "angle-right", rtl: !@query.new_record?) %>
<%= l(:label_filter_plural) %>
</legend>
<div style="<%= @query.new_record? ? "" : "display: none;" %>">
@@ -26,7 +26,7 @@
<fieldset id="options" class="collapsible collapsed">
<legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
- <%= sprite_icon("angle-right") %>
+ <%= sprite_icon("angle-right", rtl: true) %>
<%= l(:label_options) %>
</legend>
<div style="display: none;">
@@ -308,7 +308,7 @@
style += "width:#{width}px;"
style += "height:#{height}px;"
style += "font-size:0.7em;"
- clss = "gantt_hdr"
+ clss = +"gantt_hdr"
clss << " nwday" if @gantt.non_working_week_days.include?(wday)
%>
<%= content_tag(:div, :style => style, :class => clss) do %>
@@ -339,7 +339,7 @@
style += "width: #{width}px;"
style += "height: #{height}px;"
style += "font-size:0.7em;"
- clss = "gantt_hdr"
+ clss = +"gantt_hdr"
clss << " nwday" if @gantt.non_working_week_days.include?(g_date.cwday)
%>
<%= content_tag(:div, :style => style, :class => clss) do %>
diff --git a/app/views/help/wiki_syntax/common_mark/en/wiki_syntax_common_mark.html.erb b/app/views/help/wiki_syntax/common_mark/en/wiki_syntax_common_mark.html.erb
index 486b96424..a650b2751 100644
--- a/app/views/help/wiki_syntax/common_mark/en/wiki_syntax_common_mark.html.erb
+++ b/app/views/help/wiki_syntax/common_mark/en/wiki_syntax_common_mark.html.erb
@@ -81,6 +81,14 @@
<th></th><td>HTML is &lt;del&gt;not&lt;/del&gt; &lt;u&gt;allowed&lt;/u&gt;.</td><td>HTML is <del>not</del> <u>allowed</u>.</td>
</tr>
+<tr><th colspan="3">Alerts <span class="more_info">(<a href="<%= help_wiki_syntax_path(:detailed, anchor: "16") %>" target="_blank">more</a>)</span></th></tr>
+<tr><th></th><td>> [!NOTE]<br>> You can use alerts like [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], and [!CAUTION].</td><td>
+<div class="markdown-alert markdown-alert-note">
+<p class="markdown-alert-title">Note</p>
+<p>You can use alerts like [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], and [!CAUTION].</p>
+</div>
+</td></tr>
+
</table>
<p><a href="<%= help_wiki_syntax_path(:detailed) %>" onclick="window.open('<%= help_wiki_syntax_path(:detailed) %>', '', ''); return false;">More Information</a></p>
diff --git a/app/views/help/wiki_syntax/common_mark/en/wiki_syntax_detailed_common_mark.html.erb b/app/views/help/wiki_syntax/common_mark/en/wiki_syntax_detailed_common_mark.html.erb
index a47a570f1..a74094460 100644
--- a/app/views/help/wiki_syntax/common_mark/en/wiki_syntax_detailed_common_mark.html.erb
+++ b/app/views/help/wiki_syntax/common_mark/en/wiki_syntax_detailed_common_mark.html.erb
@@ -27,6 +27,7 @@
<li><a href='#12'>Macros</a></li>
<li><a href='#13'>Code highlighting</a></li>
<li><a href='#15'>Raw HTML</a></li>
+ <li><a href='#16'>Alerts</a></li>
</ul>
<h2><a name="2" class="wiki-page"></a>Links</h2>
@@ -359,8 +360,8 @@ It can be expanded by clicking a link.
<p>The <strong>style</strong> attribute can be used in raw HTML to apply custom formatting. The following CSS properties are allowed:</p>
<pre><code>
color background-color
- width
- height
+ width min-width max-width
+ height min-height max-height
padding padding-left padding-right padding-top padding-bottom
margin margin-left margin-right margin-top margin-bottom
border border-left border-right border-top border-bottom border-radius border-style border-collapse border-spacing
@@ -369,5 +370,52 @@ It can be expanded by clicking a link.
float
</code></pre>
+ <h2><a name="16" class="wiki-page"></a>Alerts</h2>
+
+ <p>
+ <dl>
+ <dt><code>NOTE</code></dt>
+ <dd>
+ <pre><code>> [!NOTE]<br>> Wiki page edits are preserved as history, allowing you to restore previous versions if needed.</code></pre>
+ <div class="markdown-alert markdown-alert-note">
+ <p class="markdown-alert-title">Note</p>
+ <p>Wiki page edits are preserved as history, allowing you to restore previous versions if needed.</p>
+ </div>
+ </dd>
+ <dt><code>TIP</code></dt>
+ <dd>
+ <pre><code>> [!TIP]<br>> To quickly review the update history of an issue, use the "History" tab for convenient access.</code></pre>
+ <div class="markdown-alert markdown-alert-tip">
+ <p class="markdown-alert-title">Tip</p>
+ <p>To quickly review the update history of an issue, use the "History" tab for convenient access.</p>
+ </div>
+ </dd>
+ <dt><code>WARNING</code></dt>
+ <dd>
+ <pre><code>> [!WARNING]<br>> Deleting an issue is a permanent action. Be certain it is truly necessary before proceeding.</code></pre>
+ <div class="markdown-alert markdown-alert-warning">
+ <p class="markdown-alert-title">Warning</p>
+ <p>Deleting an issue is a permanent action. Be certain it is truly necessary before proceeding.</p>
+ </div>
+ </dd>
+ <dt><code>IMPORTANT</code></dt>
+ <dd>
+ <pre><code>> [!IMPORTANT]<br>> Changing role permissions can affect user access across all projects, so be mindful of potential impacts.</code></pre>
+ <div class="markdown-alert markdown-alert-important">
+ <p class="markdown-alert-title">Important</p>
+ <p>Changing role permissions can affect user access across all projects, so be mindful of potential impacts.</p>
+ </div>
+ </dd>
+ <dt><code>CAUTION</code></dt>
+ <dd>
+ <pre><code>> [!CAUTION]<br>> When installing plugins, make sure to verify compatibility. Version differences can cause unexpected behavior.</code></pre>
+ <div class="markdown-alert markdown-alert-caution">
+ <p class="markdown-alert-title">Caution</p>
+ <p>When installing plugins, make sure to verify compatibility. Version differences can cause unexpected behavior.</p>
+ </div>
+ </dd>
+ </dl>
+ </p>
+
</body>
</html>
diff --git a/app/views/imports/_issues_mapping.html.erb b/app/views/imports/_issues_mapping.html.erb
index 86e2dd89a..539bad9fe 100644
--- a/app/views/imports/_issues_mapping.html.erb
+++ b/app/views/imports/_issues_mapping.html.erb
@@ -7,7 +7,7 @@
<fieldset class="box tabular collapsible collapsed">
<legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
- <%= sprite_icon("angle-right") %>
+ <%= sprite_icon("angle-right", rtl: true) %>
<%= l(:label_relations_mapping) %>
</legend>
<div id="relations-mapping" style="display: none;">
diff --git a/app/views/issue_relations/_form.html.erb b/app/views/issue_relations/_form.html.erb
index 97fe78cc6..b55e93eac 100644
--- a/app/views/issue_relations/_form.html.erb
+++ b/app/views/issue_relations/_form.html.erb
@@ -2,6 +2,7 @@
<% if @unsaved_relations && @unsaved_relations.any? %>
<% unsaved_relations_ids = @unsaved_relations.map(&:issue_to_id).compact.join(", ") %>
<div id="errorExplanation">
+ <%= notice_icon('error') %>
<ul>
<% relation_error_messages(@unsaved_relations).each do |message| %>
<li><%= message %></li>
diff --git a/app/views/issues/_action_menu.html.erb b/app/views/issues/_action_menu.html.erb
index 6009df89e..8b5d3021d 100644
--- a/app/views/issues/_action_menu.html.erb
+++ b/app/views/issues/_action_menu.html.erb
@@ -3,7 +3,7 @@
:onclick => 'showAndScrollTo("update", "issue_notes"); return false;',
:class => 'icon icon-edit ', :accesskey => accesskey(:edit) if @issue.editable? %>
<%= link_to sprite_icon('time-add', l(:button_log_time)), new_issue_time_entry_path(@issue),
- :class => 'icon icon-time-add ' if User.current.allowed_to?(:log_time, @project) %>
+ :class => 'icon icon-time-add ' if @issue.time_loggable? %>
<%= watcher_link(@issue, User.current) %>
<%= link_to sprite_icon('copy', l(:button_copy)), project_copy_issue_path(@project, @issue),
:class => 'icon icon-copy ' if User.current.allowed_to?(:copy_issues, @project) && Issue.allowed_target_projects.any? %>
diff --git a/app/views/issues/_conflict.html.erb b/app/views/issues/_conflict.html.erb
index ea4c35d7d..596584d14 100644
--- a/app/views/issues/_conflict.html.erb
+++ b/app/views/issues/_conflict.html.erb
@@ -1,4 +1,5 @@
<div class="conflict">
+ <%= notice_icon('warning') %>
<%= l(:notice_issue_update_conflict) %>
<% if @conflict_journals.present? %>
<div class="conflict-details">
diff --git a/app/views/issues/_edit.html.erb b/app/views/issues/_edit.html.erb
index f00b65750..5692eb80b 100644
--- a/app/views/issues/_edit.html.erb
+++ b/app/views/issues/_edit.html.erb
@@ -9,7 +9,7 @@
</div>
</fieldset>
<% end %>
- <% if User.current.allowed_to?(:log_time, @issue.project) %>
+ <% if @issue.time_loggable? %>
<fieldset class="tabular" id="log_time"><legend><%= l(:button_log_time) %></legend>
<%= labelled_fields_for :time_entry, @time_entry do |time_entry| %>
<div class="splitcontent">
diff --git a/app/views/issues/_list.html.erb b/app/views/issues/_list.html.erb
index df562c6f2..e8b151ef6 100644
--- a/app/views/issues/_list.html.erb
+++ b/app/views/issues/_list.html.erb
@@ -15,7 +15,7 @@
<% query.inline_columns.each do |column| %>
<%= column_header(query, column, query_options) %>
<% end %>
- <th class="buttons"></th>
+ <th class="buttons hide-when-print"></th>
</tr>
</thead>
<tbody>
@@ -36,7 +36,7 @@
<% query.inline_columns.each do |column| %>
<%= content_tag('td', column_content(column, issue), :class => column.css_classes) %>
<% end %>
- <td class="buttons"><%= link_to_context_menu %></td>
+ <td class="buttons hide-when-print"><%= link_to_context_menu %></td>
</tr>
<% query.block_columns.each do |column|
if (text = column_content(column, issue)) && text.present? -%>
diff --git a/app/views/issues/bulk_edit.html.erb b/app/views/issues/bulk_edit.html.erb
index a379305be..b8bf87beb 100644
--- a/app/views/issues/bulk_edit.html.erb
+++ b/app/views/issues/bulk_edit.html.erb
@@ -2,6 +2,7 @@
<% if @saved_issues && @unsaved_issues.present? %>
<div id="errorExplanation">
+ <%= notice_icon('error') %>
<span>
<%= l(:notice_failed_to_save_issues,
:count => @unsaved_issues.size,
@@ -241,6 +242,7 @@
<% if @values_by_custom_field.present? %>
<div class="flash warning">
+ <%= notice_icon('warning') %>
<%= l(:warning_fields_cleared_on_bulk_edit) %>:<br />
<%= safe_join(@values_by_custom_field.map {|field, ids| content_tag "span", "#{field.name} (#{ids.size})"}, ', ') %>
</div>
diff --git a/app/views/issues/index.html.erb b/app/views/issues/index.html.erb
index af2510827..70f0b740a 100644
--- a/app/views/issues/index.html.erb
+++ b/app/views/issues/index.html.erb
@@ -35,9 +35,9 @@
<% end %>
<% other_formats_links do |f| %>
- <%= f.link_to_with_query_parameters 'Atom', :key => User.current.atom_key %>
<%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '350px'); return false;" %>
<%= f.link_to_with_query_parameters 'PDF' %>
+ <%= f.link_to_with_query_parameters 'Atom', :key => User.current.atom_key %>
<% end %>
<div id="csv-export-options" style="display:none;">
diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb
index 8f732032a..696b8f0ec 100644
--- a/app/views/issues/show.html.erb
+++ b/app/views/issues/show.html.erb
@@ -1,7 +1,3 @@
-<% content_for :header_tags do %>
- <%= javascripts_for_quote_reply_include_tag %>
-<% end %>
-
<%= render :partial => 'action_menu' %>
<h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %>
@@ -37,8 +33,18 @@
<%= assignee_avatar(@issue.assigned_to, :size => "22", :class => "gravatar-child") if @issue.assigned_to %>
</div>
-<div class="subject">
-<%= render_issue_subject_with_tree(@issue) %>
+<div data-controller="sticky-issue-header">
+ <div class="subject" data-sticky-issue-header-target="original">
+ <%= render_issue_subject_with_tree(@issue) %>
+ </div>
+ <div id="sticky-issue-header" data-sticky-issue-header-target="stickyHeader" class="issue">
+ <span class="issue-heading"><%= issue_heading(@issue) %>:</span>
+ <span class="subject"><%= @issue.subject %></span>
+ </div>
+</div>
+
+<div class="reaction">
+ <%= reaction_button @issue %>
</div>
<p class="author">
<%= authoring @issue.created_on, @issue.author %>.
@@ -86,13 +92,13 @@ end %>
<% if @issue.description? %>
<hr />
-<div class="description">
+<div class="description" data-controller="quote-reply">
<div class="contextual">
- <%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>
+ <%= quote_reply_button(url: quoted_issue_path(@issue)) if @issue.notes_addable? %>
</div>
<p><strong><%=l(:field_description)%></strong></p>
- <div id="issue_description_wiki" class="wiki">
+ <div id="issue_description_wiki" class="wiki" data-quote-reply-target="content">
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
</div>
</div>
@@ -125,15 +131,15 @@ 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>
<%= render partial: 'action_menu_edit' unless User.current.wants_comments_in_reverse_order? %>
<% other_formats_links do |f| %>
- <%= f.link_to 'Atom', :url => {:key => User.current.atom_key} %>
<%= f.link_to 'PDF' %>
+ <%= f.link_to 'Atom', :url => {:key => User.current.atom_key} %>
<% end %>
<% html_title "#{@issue.tracker.name} ##{@issue.id}: #{@issue.subject}" %>
diff --git a/app/views/issues/tabs/_changesets.html.erb b/app/views/issues/tabs/_changesets.html.erb
index bf00dcb7b..2df4d40c2 100644
--- a/app/views/issues/tabs/_changesets.html.erb
+++ b/app/views/issues/tabs/_changesets.html.erb
@@ -1,27 +1,29 @@
<% @changesets.each do |changeset| %>
<div id="changeset-<%= changeset.id %>" class="changeset journal">
- <div class="note">
- <h4 class='note-header'>
- <%= avatar(changeset.user, :size => "24") %>
- <%= authoring changeset.committed_on, changeset.author, :label => :label_added_time_by %>
+ <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>
- <p>
- <%= "#{changeset.project.name} - " unless changeset.project == project %>
- <%= link_to_revision(changeset, changeset.repository,
- :text => "#{l(:label_revision)} #{changeset.format_identifier}") %>
- <% if changeset.filechanges.any? && User.current.allowed_to?(:browse_repository, changeset.project) %>
- (<%= link_to(l(:label_diff),
- :controller => 'repositories',
- :action => 'diff',
- :id => changeset.project,
- :repository_id => changeset.repository.identifier_param,
- :path => "",
- :rev => changeset.identifier) %>)
- <% end %></p>
-
- <div class="wiki changeset-comments">
- <%= format_changeset_comments changeset %>
- </div>
+ <div class="journal-content">
+ <p>
+ <%= "#{changeset.project.name} - " unless changeset.project == project %>
+ <%= link_to_revision(changeset, changeset.repository,
+ :text => "#{l(:label_revision)} #{changeset.format_identifier}") %>
+ <% if changeset.filechanges.any? && User.current.allowed_to?(:browse_repository, changeset.project) %>
+ (<%= link_to(l(:label_diff),
+ :controller => 'repositories',
+ :action => 'diff',
+ :id => changeset.project,
+ :repository_id => changeset.repository.identifier_param,
+ :path => "",
+ :rev => changeset.identifier) %>)
+ <% end %>
+ </p>
+ <div class="wiki changeset-comments">
+ <%= format_changeset_comments changeset %>
+ </div>
</div>
</div>
<%= call_hook(:view_issues_history_changeset_bottom, { :changeset => changeset }) %>
diff --git a/app/views/issues/tabs/_history.html.erb b/app/views/issues/tabs/_history.html.erb
index aa5795400..b416a9d37 100644
--- a/app/views/issues/tabs/_history.html.erb
+++ b/app/views/issues/tabs/_history.html.erb
@@ -5,34 +5,39 @@
<% reply_links = issue.notes_addable? -%>
<% for journal in journals %>
- <div id="change-<%= journal.id %>" class="<%= journal.css_classes %>">
+ <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'>
- <%= avatar(journal.user) %>
- <%= authoring journal.created_on, journal.user, :label => :label_updated_time_by %>
- <%= render_private_notes_indicator(journal) %>
- <%= render_journal_update_info(journal) %>
- </h4>
-
- <% if journal.details.any? %>
- <ul class="details">
- <% details_to_strings(journal.visible_details).each do |string| %>
- <li><%= string %></li>
- <% end %>
- </ul>
- <% if Setting.thumbnails_enabled? && (thumbnail_attachments = journal_thumbnail_attachments(journal)).any? %>
- <div class="thumbnails">
- <% thumbnail_attachments.each do |attachment| %>
- <%= thumbnail_tag(attachment) %>
+ <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="journal-details">
+ <% details_to_strings(journal.visible_details).each do |string| %>
+ <li><%= string %></li>
+ <% end %>
+ </ul>
+ <% if Setting.thumbnails_enabled? && (thumbnail_attachments = journal_thumbnail_attachments(journal)).any? %>
+ <div class="thumbnails">
+ <% thumbnail_attachments.each do |attachment| %>
+ <%= thumbnail_tag(attachment) %>
+ <% end %>
+ </div>
+ <% end %>
<% end %>
+ <%= render_notes(issue, journal, :reply_links => reply_links) unless journal.notes.blank? %>
</div>
- <% end %>
- <% end %>
- <%= render_notes(issue, journal, :reply_links => reply_links) unless journal.notes.blank? %>
</div>
</div>
<%= call_hook(:view_issues_history_journal_bottom, { :journal => journal }) %>
diff --git a/app/views/issues/tabs/_time_entries.html.erb b/app/views/issues/tabs/_time_entries.html.erb
index 4cbf5d01e..86a23d0c5 100644
--- a/app/views/issues/tabs/_time_entries.html.erb
+++ b/app/views/issues/tabs/_time_entries.html.erb
@@ -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 ' %>
+ <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>
- </div>
- <% end -%>
- <h4 class='note-header'>
- <%= avatar(time_entry.user, :size => "24") %>
- <%= authoring time_entry.created_on, time_entry.user, :label => :label_added_time_by %>
+ <% 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">
- <li>
- <strong><%= l(:label_time_entry_plural) %></strong>:
- <%= l_hours_short time_entry.hours %>
- </li>
- </ul>
- <p><%= time_entry.comments %></p>
+ <div class="journal-content">
+ <ul class="journal-details">
+ <li>
+ <strong><%= l(:label_time_entry_plural) %></strong>:
+ <%= l_hours_short time_entry.hours %>
+ </li>
+ </ul>
+ <div class="journal-note">
+ <%= time_entry.comments %>
+ </div>
</div>
</div>
<%= call_hook(:view_issues_history_time_entry_bottom, { :time_entry => time_entry }) %>
diff --git a/app/views/journals/update.js.erb b/app/views/journals/update.js.erb
index 227d169fc..cf6bcd28f 100644
--- a/app/views/journals/update.js.erb
+++ b/app/views/journals/update.js.erb
@@ -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)) %>');
@@ -15,6 +15,8 @@
journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
}
setupWikiTableSortableHeader();
+ setupCopyButtonsToPreElements();
+ setupHoverTooltips();
<% end %>
<%= call_hook(:view_journals_update_js_bottom, { :journal => @journal }) %>
diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb
index 3432cb655..e982f534c 100644
--- a/app/views/layouts/base.html.erb
+++ b/app/views/layouts/base.html.erb
@@ -10,8 +10,10 @@
<%= favicon %>
<%= stylesheet_link_tag 'jquery/jquery-ui-1.13.2', 'tribute-5.1.3', 'application', 'responsive', :media => 'all' %>
<%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %>
+<%= javascript_importmap_tags %>
<%= javascript_heads %>
<%= heads_for_theme %>
+<%= heads_for_i18n %>
<%= heads_for_auto_complete(@project) %>
<%= call_hook :view_layouts_base_html_head %>
<!-- page specific tags -->
@@ -106,7 +108,7 @@
<% if sidebar_content? %>
<div id="sidebar-switch-panel" style="visibility: hidden;">
<a id="sidebar-switch-button" class="" href="#">
- <%= sprite_icon("chevrons-right", size: 20) %></a>
+ <%= sprite_icon("chevrons-right", size: 20, rtl: true) %></a>
</div>
<%= javascript_tag "$('#sidebar-switch-panel').css('visibility', 'visible');" %>
<% end %>
@@ -129,6 +131,7 @@
<div id="ajax-indicator" style="display:none;"><span><%= l(:label_loading) %></span></div>
<div id="ajax-modal" style="display:none;"></div>
+<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div>
</div>
<%= call_hook :view_layouts_base_body_bottom %>
diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb
index b265cc962..e60c803b7 100644
--- a/app/views/messages/show.html.erb
+++ b/app/views/messages/show.html.erb
@@ -1,99 +1,104 @@
-<% content_for :header_tags do %>
- <%= javascripts_for_quote_reply_include_tag %>
-<% end %>
-
<%= board_breadcrumb(@message) %>
-<div class="contextual">
+<div data-controller="quote-reply">
+ <div class="contextual">
<%= watcher_link(@topic, User.current) %>
- <%= quote_reply(
- url_for(:action => 'quote', :id => @topic, :format => 'js'),
- '#message_topic_wiki'
+ <%= quote_reply_button(
+ url: url_for(action: 'quote', id: @topic, format: 'js')
) 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>
+ ) if @message.destroyable_by?(User.current) %>
+ </div>
-<h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
+ <h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
-<div class="message">
-<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
-<div id="message_topic_wiki" class="wiki">
-<%= textilizable(@topic, :content) %>
-</div>
-<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
+ <div class="message">
+ <div class="reaction">
+ <%= reaction_button @topic %>
+ </div>
+ <p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
+ <div id="message_topic_wiki" class="wiki" data-quote-reply-target="content">
+ <%= textilizable(@topic, :content) %>
+ </div>
+ <%= 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 %>
- <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}" %>">
- <div class="contextual">
- <%= quote_reply(
- url_for(:action => 'quote', :id => message, :format => 'js'),
- "#message-#{message.id} .wiki",
- icon_only: true
- ) if !@topic.locked? && authorize_for('messages', 'reply') %>
- <%= link_to(
- sprite_icon('edit', l(:button_edit), icon_only: true),
- {: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},
- :method => :post,
- :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 %>
- </h4>
- <div class="wiki"><%= textilizable message, :content, :attachments => message.attachments %></div>
- <%= link_to_attachments message, :author => false, :thumbnails => true %>
+ <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 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'),
+ icon_only: true
+ ) if !@topic.locked? && authorize_for('messages', 'reply') %>
+ <%= link_to(
+ sprite_icon('edit', l(:button_edit), icon_only: true),
+ { :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 },
+ :method => :post,
+ :data => { :confirm => l(:text_are_you_sure) },
+ :title => l(:button_delete),
+ :class => 'icon icon-del'
+ ) if message.destroyable_by?(User.current) %>
+ </span>
+ </h4>
+ <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>
-<% end %>
-</div>
-<span class="pagination"><%= pagination_links_full @reply_pages, @reply_count, :per_page_links => false %></span>
+ <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} %>
- <%= submit_tag l(:button_submit) %>
-<% end %>
-</div>
+ <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 %>
<% html_title @topic.subject %>
<% content_for :sidebar do %>
<% if User.current.allowed_to?(:add_message_watchers, @project) ||
- (@topic.watchers.present? && User.current.allowed_to?(:view_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 %>
diff --git a/app/views/my/account.html.erb b/app/views/my/account.html.erb
index c8706a5f5..95afbabac 100644
--- a/app/views/my/account.html.erb
+++ b/app/views/my/account.html.erb
@@ -1,6 +1,7 @@
<div class="contextual">
<%= additional_emails_link(@user) %>
<%= link_to(sprite_icon('key', l(:button_change_password)), { :action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %>
+<%= link_to(sprite_icon('apps', l('label_oauth_authorized_application_plural')), oauth_authorized_applications_path, :class => 'icon icon-applications') if Setting.rest_api_enabled? %>
<%= call_hook(:view_my_account_contextual, :user => @user)%>
</div>
diff --git a/app/views/news/show.html.erb b/app/views/news/show.html.erb
index d07a09eb7..f80af8f18 100644
--- a/app/views/news/show.html.erb
+++ b/app/views/news/show.html.erb
@@ -22,30 +22,43 @@
</div>
<% end %>
-<p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
-<span class="author"><%= authoring @news.created_on, @news.author %></span></p>
-<div class="wiki">
-<%= textilizable(@news, :description) %>
+<div class="news">
+ <div class="reaction">
+ <%= reaction_button @news %>
+ </div>
+ <p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
+ <span class="author"><%= authoring @news.created_on, @news.author %></span></p>
+ <div class="wiki">
+ <%= textilizable(@news, :description) %>
+ </div>
+ <%= link_to_attachments @news %>
</div>
-<%= link_to_attachments @news %>
<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| %>
- <% next if comment.new_record? %>
- <div class="contextual">
- <%= 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">
- <%= textilizable(comment.comments) %>
+ <div class="message reply journal-entry" id="<%= "message-#{comment.id}" %>">
+ <% next if comment.new_record? %>
+ <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' %>
+ </span>
+ </h4>
+ <div class="wiki journal-content">
+ <%= textilizable(comment.comments) %>
+ </div>
</div>
<% end if @comments.any? %>
</div>
diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb
index 3a1c047c9..875e736b1 100644
--- a/app/views/projects/index.html.erb
+++ b/app/views/projects/index.html.erb
@@ -31,10 +31,10 @@
<% end %>
<% other_formats_links do |f| %>
- <%= f.link_to 'Atom', :url => {:key => User.current.atom_key} %>
<% if @query.display_type == 'list' %>
<%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '350px'); return false;" %>
<% end %>
+ <%= f.link_to 'Atom', :url => {:key => User.current.atom_key} %>
<% end %>
<% html_title(l(:label_project_plural)) -%>
diff --git a/app/views/queries/_filters.html.erb b/app/views/queries/_filters.html.erb
index a1118f6ab..42756775a 100644
--- a/app/views/queries/_filters.html.erb
+++ b/app/views/queries/_filters.html.erb
@@ -22,6 +22,5 @@ $(document).ready(function(){
<%= select_tag 'add_filter_select', filters_options_for_select(query), :name => nil %>
</div>
-<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div>
<%= hidden_field_tag 'f[]', '' %>
<% include_calendar_headers_tags %>
diff --git a/app/views/queries/_form.html.erb b/app/views/queries/_form.html.erb
index b70ef53df..8b52383de 100644
--- a/app/views/queries/_form.html.erb
+++ b/app/views/queries/_form.html.erb
@@ -32,7 +32,7 @@
<% unless @query.is_a?(ProjectQuery) %>
<p><label for="query_is_for_all"><%=l(:field_is_for_all)%></label>
- <%= check_box_tag 'query_is_for_all', 1, @query.project.nil?, :class => (User.current.admin? ? '' : 'disable-unless-private') %></p>
+ <%= check_box_tag 'query_is_for_all', 1, @query.project.nil?, :disabled => (!@query.new_record? && @query.project.nil?), :class => (User.current.admin? ? '' : 'disable-unless-private') %></p>
<% end %>
<% unless params[:calendar] %>
diff --git a/app/views/queries/_query_form.html.erb b/app/views/queries/_query_form.html.erb
index d04cd290e..77094e16e 100644
--- a/app/views/queries/_query_form.html.erb
+++ b/app/views/queries/_query_form.html.erb
@@ -6,7 +6,7 @@
<div id="query_form_content">
<fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
<legend onclick="toggleFieldset(this);" class="icon icon-<%= @query.new_record? ? "expanded" : "collapsed" %>">
- <%= sprite_icon(@query.new_record? ? "angle-down" : "angle-right") %>
+ <%= sprite_icon(@query.new_record? ? "angle-down" : "angle-right", rtl: !@query.new_record?) %>
<%= l(:label_filter_plural) %>
</legend>
<div style="<%= @query.new_record? ? "" : "display: none;" %>">
@@ -17,7 +17,7 @@
<% if @query.available_columns.any? %>
<fieldset id="options" class="collapsible collapsed">
<legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
- <%= sprite_icon("angle-right") %>
+ <%= sprite_icon("angle-right", rtl: true) %>
<%= l(:label_options) %>
</legend>
<div class="hidden">
diff --git a/app/views/reactions/_replace_button.js.erb b/app/views/reactions/_replace_button.js.erb
new file mode 100644
index 000000000..a5c923ea4
--- /dev/null
+++ b/app/views/reactions/_replace_button.js.erb
@@ -0,0 +1,7 @@
+(() => {
+ const button = $('[data-reaction-button-id=<%= reaction_id_for @object %>]');
+
+ removeHoverTooltips(button);
+ button.html($('<%=j reaction_button @object %>').children());
+ setupHoverTooltips(button);
+})();
diff --git a/app/views/reactions/create.js.erb b/app/views/reactions/create.js.erb
new file mode 100644
index 000000000..20f3cc7ed
--- /dev/null
+++ b/app/views/reactions/create.js.erb
@@ -0,0 +1 @@
+<%= render 'replace_button' %>
diff --git a/app/views/reactions/destroy.js.erb b/app/views/reactions/destroy.js.erb
new file mode 100644
index 000000000..20f3cc7ed
--- /dev/null
+++ b/app/views/reactions/destroy.js.erb
@@ -0,0 +1 @@
+<%= render 'replace_button' %>
diff --git a/app/views/repositories/_breadcrumbs.html.erb b/app/views/repositories/_breadcrumbs.html.erb
index 4a5903e14..15b7b2c5f 100644
--- a/app/views/repositories/_breadcrumbs.html.erb
+++ b/app/views/repositories/_breadcrumbs.html.erb
@@ -9,7 +9,7 @@ breadcrumbs << link_to(
@repository.identifier.presence || 'root', :action => 'show',
:id => @project, :repository_id => @repository.identifier_param,
:path => nil, :rev => @rev)
-link_path = ''
+link_path = +''
dirs.each do |dir|
next if dir.blank?
diff --git a/app/views/repositories/_dir_list_content.html.erb b/app/views/repositories/_dir_list_content.html.erb
index 991400d7a..aed3dcc0c 100644
--- a/app/views/repositories/_dir_list_content.html.erb
+++ b/app/views/repositories/_dir_list_content.html.erb
@@ -14,7 +14,7 @@
:path => to_path_param(ent_path),
:rev => @rev,
:depth => (depth + 1),
- :parent_id => tr_id)) %>');"><%= sprite_icon('angle-right') %></span>
+ :parent_id => tr_id)) %>');"><%= sprite_icon('angle-right', rtl: true) %></span>
<% end %>
<%= link_to file_icon(entry, ent_name),
{:action => (entry.is_dir? ? 'show' : 'entry'), :id => @project, :repository_id => @repository.identifier_param, :path => to_path_param(ent_path), :rev => @rev},
diff --git a/app/views/repositories/annotate.html.erb b/app/views/repositories/annotate.html.erb
index db91ef913..e532714e4 100644
--- a/app/views/repositories/annotate.html.erb
+++ b/app/views/repositories/annotate.html.erb
@@ -51,7 +51,10 @@
</table>
</div>
<% else %>
-<p id="errorExplanation"><%= @error_message %></p>
+<p id="errorExplanation">
+ <%= notice_icon('error') %>
+ <%= @error_message %>
+</p>
<% end %>
<% html_title(l(:button_annotate)) -%>
diff --git a/app/views/repositories/revision.html.erb b/app/views/repositories/revision.html.erb
index ba716dd41..9423e5438 100644
--- a/app/views/repositories/revision.html.erb
+++ b/app/views/repositories/revision.html.erb
@@ -30,11 +30,11 @@
<% if User.current.allowed_to?(:browse_repository, @project) %>
<ul id="changes-legend">
-<li class="change change-A"><%= l(:label_added) %></li>
-<li class="change change-M"><%= l(:label_modified) %></li>
-<li class="change change-C"><%= l(:label_copied) %></li>
-<li class="change change-R"><%= l(:label_renamed) %></li>
-<li class="change change-D"><%= l(:label_deleted) %></li>
+<li class="change change-A"><%= scm_change_icon("A", (:label_added)) %></li>
+<li class="change change-M"><%= scm_change_icon("M", l(:label_modified)) %></li>
+<li class="change change-C"><%= scm_change_icon("C", l(:label_copied)) %></li>
+<li class="change change-R"><%= scm_change_icon("R", l(:label_renamed)) %></li>
+<li class="change change-D"><%= scm_change_icon("D", l(:label_deleted)) %></li>
</ul>
<div class="changeset-changes">
diff --git a/app/views/roles/permissions.html.erb b/app/views/roles/permissions.html.erb
index 63a1267fc..573fbc9fa 100644
--- a/app/views/roles/permissions.html.erb
+++ b/app/views/roles/permissions.html.erb
@@ -3,7 +3,7 @@
<div class="hide-when-print">
<fieldset id="filters" class="collapsible collapsed">
<legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
- <%= sprite_icon("angle-right") %>
+ <%= sprite_icon("angle-right", rtl: true) %>
<%= l(:label_filter_plural) %>
</legend>
<div style="display: none;">
diff --git a/app/views/search/index.html.erb b/app/views/search/index.html.erb
index 7b5fc4f36..c17bbd8ea 100644
--- a/app/views/search/index.html.erb
+++ b/app/views/search/index.html.erb
@@ -25,7 +25,7 @@
<fieldset class="collapsible collapsed">
<legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
- <%= sprite_icon("angle-right") %>
+ <%= sprite_icon("angle-right", rtl: true) %>
<%= l(:label_options) %>
</legend>
<div id="options-content" style="display:none;">
diff --git a/app/views/settings/_display.html.erb b/app/views/settings/_display.html.erb
index 62c53dfbb..3b2f95798 100644
--- a/app/views/settings/_display.html.erb
+++ b/app/views/settings/_display.html.erb
@@ -22,7 +22,12 @@
<p><%= setting_check_box :gravatar_enabled, :data => {:enables => '#settings_gravatar_default'} %>
<em class="info"><%= t(:text_avatar_server_config_html, :url => Redmine::Configuration['avatar_server_url']) %></em></p>
-<p><%= setting_select :gravatar_default, gravatar_default_setting_options, :blank => :label_none %></p>
+<p>
+ <%= setting_select :gravatar_default, gravatar_default_setting_options, :blank => :label_none %>
+ <em class="<%= Setting.gravatar_default == "initials" ? "info" : "hidden" %>">
+ <%= t(:text_setting_gravatar_default_initials_html) %>
+ </em>
+</p>
<p><%= setting_check_box :thumbnails_enabled, :data => {:enables => '#settings_thumbnails_size'} %></p>
@@ -35,3 +40,18 @@
<%= submit_tag l(:button_save) %>
<% end %>
+
+<%= javascript_tag do %>
+ $('#settings_gravatar_default').on('change', function(e){
+ const gravatar_default = e.target.value;
+ const em = e.target.parentElement.getElementsByTagName('em')[0];
+
+ if (gravatar_default === 'initials') {
+ em.classList.remove('hidden');
+ em.classList.add('info');
+ } else {
+ em.classList.add('hidden');
+ em.classList.remove('info');
+ }
+ });
+<% end %> \ No newline at end of file
diff --git a/app/views/settings/_general.html.erb b/app/views/settings/_general.html.erb
index 043067f18..44206b6c2 100644
--- a/app/views/settings/_general.html.erb
+++ b/app/views/settings/_general.html.erb
@@ -37,6 +37,8 @@
<p><%= setting_text_field :feeds_limit, :size => 6 %></p>
+<p><%= setting_check_box :reactions_enabled %></p>
+
<%= call_hook(:view_settings_general_form) %>
</div>
diff --git a/app/views/settings/_issues.html.erb b/app/views/settings/_issues.html.erb
index 0e978fa6b..3a3a36d1b 100644
--- a/app/views/settings/_issues.html.erb
+++ b/app/views/settings/_issues.html.erb
@@ -62,5 +62,24 @@
</p>
</fieldset>
+<fieldset class="box">
+ <legend><%= l(:setting_related_issues_default_columns) %></legend>
+ <div id="list-definition">
+ <div>
+ <%= render_query_columns_selection(
+ IssueQuery.new(:column_names => Setting.related_issues_default_columns),
+ :name => 'settings[related_issues_default_columns]') %>
+ <%= javascript_tag do %>
+ $('#available_settings_related_issues_default_columns option[value="tracker"]').remove();
+ $('#available_settings_related_issues_default_columns option[value="subject"]').remove();
+ <% end %>
+ </div>
+ </div>
+
+ <div class="tabular settings">
+ <p><%= setting_check_box :display_related_issues_table_headers %></p>
+ </div>
+</fieldset>
+
<%= submit_tag l(:button_save) %>
<% end %>
diff --git a/app/views/settings/_timelog.html.erb b/app/views/settings/_timelog.html.erb
index c6efd966d..0e860ffd1 100644
--- a/app/views/settings/_timelog.html.erb
+++ b/app/views/settings/_timelog.html.erb
@@ -9,6 +9,8 @@
<p><%= setting_check_box :timelog_accept_0_hours %></p>
<p><%= setting_check_box :timelog_accept_future_dates %></p>
+
+<p><%= setting_check_box :timelog_accept_closed_issues %></p>
</div>
<fieldset class="box">
diff --git a/app/views/timelog/_list.html.erb b/app/views/timelog/_list.html.erb
index 1a82b5f51..aa1c1c293 100644
--- a/app/views/timelog/_list.html.erb
+++ b/app/views/timelog/_list.html.erb
@@ -11,7 +11,7 @@
<% @query.inline_columns.each do |column| %>
<%= column_header(@query, column) %>
<% end %>
- <th></th>
+ <th class="buttons hide-when-print"></th>
</tr>
</thead>
<tbody>
@@ -36,7 +36,7 @@
<% @query.inline_columns.each do |column| %>
<%= content_tag('td', column_content(column, entry), :class => column.css_classes) %>
<% end %>
- <td class="buttons">
+ <td class="buttons hide-when-print">
<% if entry.editable_by?(User.current) -%>
<%= link_to sprite_icon('edit', l(:button_edit)), edit_time_entry_path(entry),
:title => l(:button_edit),
diff --git a/app/views/timelog/bulk_edit.html.erb b/app/views/timelog/bulk_edit.html.erb
index a4cb0c8af..4f496470a 100644
--- a/app/views/timelog/bulk_edit.html.erb
+++ b/app/views/timelog/bulk_edit.html.erb
@@ -2,6 +2,7 @@
<% if @unsaved_time_entries.present? %>
<div id="errorExplanation">
+ <%= notice_icon('error') %>
<span>
<%= l(:notice_failed_to_save_time_entries,
:count => @unsaved_time_entries.size,
diff --git a/app/views/timelog/index.html.erb b/app/views/timelog/index.html.erb
index 55e2312b3..d9985e922 100644
--- a/app/views/timelog/index.html.erb
+++ b/app/views/timelog/index.html.erb
@@ -29,8 +29,8 @@
<span class="pagination"><%= pagination_links_full @entry_pages, @entry_count %></span>
<% other_formats_links do |f| %>
- <%= f.link_to_with_query_parameters 'Atom', :key => User.current.atom_key %>
<%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %>
+ <%= f.link_to_with_query_parameters 'Atom', :key => User.current.atom_key %>
<% end %>
<div id="csv-export-options" style="display:none;">
diff --git a/app/views/users/show.api.rsb b/app/views/users/show.api.rsb
index bf415795d..0681903b8 100644
--- a/app/views/users/show.api.rsb
+++ b/app/views/users/show.api.rsb
@@ -11,7 +11,7 @@ api.user do
api.passwd_changed_on @user.passwd_changed_on
api.avatar_url gravatar_url(@user.mail, {rating: nil, size: nil, default: Setting.gravatar_default}) if @user.mail && Setting.gravatar_enabled?
api.twofa_scheme @user.twofa_scheme if User.current.admin? || (User.current == @user)
- api.api_key @user.api_key if User.current.admin? || (User.current == @user)
+ api.api_key @user.api_key if (User.current.admin? || (User.current == @user && !User.current.authorized_by_oauth?))
api.status @user.status if User.current.admin?
render_api_custom_values @user.visible_custom_field_values, api
diff --git a/app/views/versions/_sidebar.html.erb b/app/views/versions/_sidebar.html.erb
index 2b197388b..a1b9452d8 100644
--- a/app/views/versions/_sidebar.html.erb
+++ b/app/views/versions/_sidebar.html.erb
@@ -42,8 +42,8 @@
</ul>
<% if @completed_versions.present? %>
<p>
- <%= link_to_function sprite_icon('angle-right', l(:label_completed_versions)),
- '$("#toggle-completed-versions").toggleClass("icon-collapsed icon-expanded"); $("#completed-versions").toggle()',
+ <%= link_to_function sprite_icon('angle-right', l(:label_completed_versions), rtl: true),
+ '$("#toggle-completed-versions").toggleClass("icon-collapsed icon-expanded"); $("#completed-versions").toggle(); toggleExpendCollapseIcon(this);',
:id => 'toggle-completed-versions', :class => 'icon icon-collapsed collapsible' %>
<ul id = "completed-versions" style = "display:none;">
<% @completed_versions.each do |version| %>
diff --git a/app/views/versions/index.html.erb b/app/views/versions/index.html.erb
index 6c3d518bc..45b254a7d 100644
--- a/app/views/versions/index.html.erb
+++ b/app/views/versions/index.html.erb
@@ -40,7 +40,7 @@
<td class="checkbox"><%= check_box_tag 'ids[]', issue.id, false, :id => nil %></td>
<td class="assigned_to"><%= assignee_avatar(issue.assigned_to, :size => 16) %></td>
<td class="subject"><%= link_to_issue(issue, :project => (@project != issue.project)) %></td>
- <td class="buttons"><%= link_to_context_menu %></td>
+ <td class="buttons hide-when-print"><%= link_to_context_menu %></td>
</tr>
<% end -%>
</table>
diff --git a/app/views/versions/show.html.erb b/app/views/versions/show.html.erb
index f83aff80d..cdd2b3029 100644
--- a/app/views/versions/show.html.erb
+++ b/app/views/versions/show.html.erb
@@ -54,7 +54,7 @@
<td class="checkbox"><%= check_box_tag 'ids[]', issue.id, false, :id => nil %></td>
<td class="assigned_to"><%= assignee_avatar(issue.assigned_to, :size => 16) %></td>
<td class="subject"><%= link_to_issue(issue, :project => (@project != issue.project)) %></td>
- <td class="buttons"><%= link_to_context_menu %></td>
+ <td class="buttons hide-when-print"><%= link_to_context_menu %></td>
</tr>
<% end %>
</table>
diff --git a/app/views/wiki/date_index.html.erb b/app/views/wiki/date_index.html.erb
index 7ee5b467d..c8acf933c 100644
--- a/app/views/wiki/date_index.html.erb
+++ b/app/views/wiki/date_index.html.erb
@@ -29,11 +29,11 @@
<% unless @pages.empty? %>
<% other_formats_links do |f| %>
- <%= f.link_to 'Atom', :url => {:controller => 'activities', :action => 'index', :id => @project, :show_wiki_edits => 1, :key => User.current.atom_key} %>
<% if User.current.allowed_to?(:export_wiki_pages, @project) %>
<%= f.link_to('PDF', :url => {:action => 'export', :format => 'pdf'}) %>
<%= f.link_to('HTML', :url => {:action => 'export'}) %>
<% end %>
+ <%= f.link_to 'Atom', :url => {:controller => 'activities', :action => 'index', :id => @project, :show_wiki_edits => 1, :key => User.current.atom_key} %>
<% end %>
<% end %>
diff --git a/app/views/wiki/index.html.erb b/app/views/wiki/index.html.erb
index 3f6d05fc2..a4afcb28e 100644
--- a/app/views/wiki/index.html.erb
+++ b/app/views/wiki/index.html.erb
@@ -22,14 +22,14 @@
<% unless @pages.empty? %>
<% other_formats_links do |f| %>
- <%= f.link_to 'Atom',
- :url => {:controller => 'activities', :action => 'index',
- :id => @project, :show_wiki_edits => 1,
- :key => User.current.atom_key} %>
<% if User.current.allowed_to?(:export_wiki_pages, @project) %>
<%= f.link_to('PDF', :url => {:action => 'export', :format => 'pdf'}) %>
<%= f.link_to('HTML', :url => {:action => 'export'}) %>
<% end %>
+ <%= f.link_to 'Atom',
+ :url => {:controller => 'activities', :action => 'index',
+ :id => @project, :show_wiki_edits => 1,
+ :key => User.current.atom_key} %>
<% end %>
<% end %>
diff --git a/app/views/wiki/show.html.erb b/app/views/wiki/show.html.erb
index 7c35463a0..4b222ef4b 100644
--- a/app/views/wiki/show.html.erb
+++ b/app/views/wiki/show.html.erb
@@ -63,7 +63,7 @@
<fieldset class="collapsible collapsed hide-when-print">
<legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
- <%= sprite_icon("angle-right") %>
+ <%= sprite_icon("angle-right", rtl: true) %>
<%= l(:label_attachment_plural) %> (<%= @page.attachments.length %>)
</legend>
<div style="display: none;">
diff --git a/app/views/workflows/edit.html.erb b/app/views/workflows/edit.html.erb
index 22f1cc48e..3265f68de 100644
--- a/app/views/workflows/edit.html.erb
+++ b/app/views/workflows/edit.html.erb
@@ -40,8 +40,8 @@
<%= render :partial => 'form', :locals => {:name => 'always', :workflows => @workflows['always']} %>
<fieldset class="collapsible" style="padding: 0; margin-top: 0.5em;">
- <legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
- <%= sprite_icon("angle-right") %>
+ <legend onclick="toggleFieldset(this);" class="icon icon-<%= @workflows['author'].present? ? "expanded" : "collapsed" %>">
+ <%= sprite_icon(@workflows['author'].present? ? "angle-down" : "angle-right", rtl: !@workflows['author'].present?) %>
<%= l(:label_additional_workflow_transitions_for_author) %>
</legend>
<div id="author_workflows" style="margin: 0.5em 0 0.5em 0;">
@@ -51,8 +51,8 @@
<%= javascript_tag "hideFieldset($('#author_workflows'))" unless @workflows['author'].present? %>
<fieldset class="collapsible" style="padding: 0;">
- <legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
- <%= sprite_icon("angle-right") %>
+ <legend onclick="toggleFieldset(this);" class="icon icon-<%= @workflows['assignee'].present? ? "expanded" : "collapsed" %>">
+ <%= sprite_icon(@workflows['assignee'].present? ? "angle-down" : "angle-right", rtl: !@workflows['assignee'].present?) %>
<%= l(:label_additional_workflow_transitions_for_assignee) %>
</legend>
<div id="assignee_workflows" style="margin: 0.5em 0 0.5em 0;">