diff options
Diffstat (limited to 'app')
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> </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?"+")":""}},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(' '.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 <del>not</del> <u>allowed</u>.</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;"> |