diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js index 3ee2e4335..833e7d8d1 100644 --- a/app/assets/javascripts/application-legacy.js +++ b/app/assets/javascripts/application-legacy.js @@ -1170,7 +1170,6 @@ $(document).ready(function(){ data: "text=" + element + '&' + attachments, success: function(data){ jstBlock.find('.wiki-preview').html(data); - setupWikiTableSortableHeader(); } }); }); @@ -1439,7 +1438,6 @@ $(document).ready(defaultFocus); $(document).ready(setupAttachmentDetail); $(document).ready(setupTabs); $(document).ready(setupFilePreviewNavigation); -$(document).ready(setupWikiTableSortableHeader); $(document).on('focus', '[data-auto-complete=true]', function(event) { inlineAutoComplete(event.target); }); diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6ea148f2d..df3bc8ddc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1801,9 +1801,6 @@ module ApplicationHelper 'rails-ujs', 'tribute-5.1.3.min' ) - 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-legacy', 'responsive') unless User.current.pref.warn_on_leaving_unsaved == '0' warn_text = escape_javascript(l(:text_warn_on_leaving_unsaved)) diff --git a/app/javascript/controllers/tablesort_controller.js b/app/javascript/controllers/tablesort_controller.js new file mode 100644 index 000000000..12db0789a --- /dev/null +++ b/app/javascript/controllers/tablesort_controller.js @@ -0,0 +1,18 @@ +/** + * Redmine - project management software + * Copyright (C) 2006- Jean-Philippe Lang + * This code is released under the GNU General Public License. + */ +import { Controller } from "@hotwired/stimulus" +import Tablesort from 'tablesort'; +import numberPlugin from 'tablesort.number'; + +// Extensions must be loaded explicitly +Tablesort.extend(numberPlugin.name, numberPlugin.pattern, numberPlugin.sort); + +// Connects to data-controller="tablesort" +export default class extends Controller { + connect() { + new Tablesort(this.element); + } +} diff --git a/app/views/journals/update.js.erb b/app/views/journals/update.js.erb index cf6bcd28f..218a164de 100644 --- a/app/views/journals/update.js.erb +++ b/app/views/journals/update.js.erb @@ -14,7 +14,6 @@ } else { journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>'); } - setupWikiTableSortableHeader(); setupCopyButtonsToPreElements(); setupHoverTooltips(); <% end %> diff --git a/config/importmap.rb b/config/importmap.rb index 3560330c1..795429f02 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -7,3 +7,5 @@ pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin "turndown" # @7.2.0 pin_all_from "app/javascript/controllers", under: "controllers" +pin "tablesort", to: "tablesort.min.js" +pin "tablesort.number", to: "tablesort.number.min.js" diff --git a/lib/redmine/wiki_formatting/common_mark/formatter.rb b/lib/redmine/wiki_formatting/common_mark/formatter.rb index 0a5137209..d9d3e9640 100644 --- a/lib/redmine/wiki_formatting/common_mark/formatter.rb +++ b/lib/redmine/wiki_formatting/common_mark/formatter.rb @@ -55,6 +55,7 @@ module Redmine SANITIZER = SanitizationFilter.new SCRUBBERS = [ SyntaxHighlightScrubber.new, + Redmine::WikiFormatting::TablesortScrubber.new, FixupAutoLinksScrubber.new, ExternalLinksScrubber.new, AlertsIconsScrubber.new diff --git a/lib/redmine/wiki_formatting/tablesort_scrubber.rb b/lib/redmine/wiki_formatting/tablesort_scrubber.rb new file mode 100644 index 000000000..01f368a8a --- /dev/null +++ b/lib/redmine/wiki_formatting/tablesort_scrubber.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# This code is released under the GNU General Public License. + +module Redmine + module WikiFormatting + class TablesortScrubber < Loofah::Scrubber + def scrub(node) + return if !Setting.wiki_tablesort_enabled? || node.name != 'table' + + rows = node.search('tr') + return if rows.size < 3 + + tr = rows.first + if tr.search('th').present? + node['data-controller'] = 'tablesort' + tr['data-sort-method'] = 'none' + tr.search('td').each do |td| + td['data-sort-method'] = 'none' + end + end + end + end + end +end diff --git a/lib/redmine/wiki_formatting/textile/formatter.rb b/lib/redmine/wiki_formatting/textile/formatter.rb index cfb941711..462301461 100644 --- a/lib/redmine/wiki_formatting/textile/formatter.rb +++ b/lib/redmine/wiki_formatting/textile/formatter.rb @@ -20,11 +20,33 @@ module Redmine module WikiFormatting module Textile - class Formatter < RedCloth3 - include ActionView::Helpers::TagHelper - include Redmine::WikiFormatting::LinksHelper + SCRUBBERS = [ + Redmine::WikiFormatting::TablesortScrubber.new + ] + + class Formatter include Redmine::WikiFormatting::SectionHelper + extend Forwardable + def_delegators :@filter, :extract_sections, :rip_offtags + + def initialize(args) + @filter = Filter.new(args) + end + + def to_html(*rules) + html = @filter.to_html(rules) + fragment = Loofah.html5_fragment(html) + SCRUBBERS.each do |scrubber| + fragment.scrub!(scrubber) + end + fragment.to_s + end + end + + class Filter < RedCloth3 + include Redmine::WikiFormatting::LinksHelper + alias :inline_auto_link :auto_link! alias :inline_auto_mailto :auto_mailto! alias :inline_restore_redmine_links :restore_redmine_links @@ -41,7 +63,7 @@ module Redmine def to_html(*rules) @toc = [] - super(*RULES).to_s + super(*RULES) end def extract_sections(index) diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index 92c2cd1d9..4d0834773 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -80,7 +80,7 @@ class ApplicationHelperTest < Redmine::HelperTest '(see inline link).', 'www.foo.bar' => 'www.foo.bar', 'http://foo.bar/page?p=1&t=z&s=' => - 'http://foo.bar/page?p=1&t=z&s=', + 'http://foo.bar/page?p=1&t=z&s=', 'http://foo.bar/page#125' => 'http://foo.bar/page#125', 'http://foo@www.bar.com' => 'http://foo@www.bar.com', 'http://foo:bar@www.bar.com' => 'http://foo:bar@www.bar.com', @@ -92,7 +92,7 @@ class ApplicationHelperTest < Redmine::HelperTest '' \ 'http://example.net/path!602815048C7B5C20!302.html', # escaping - 'http://foo"bar' => 'http://foo"bar', + 'http://foo"bar' => 'http://foo"bar', # wrap in angle brackets '' => '<http://foo.bar>', # invalid urls @@ -130,22 +130,22 @@ class ApplicationHelperTest < Redmine::HelperTest def test_inline_images to_test = { - '!http://foo.bar/image.jpg!' => '', + '!http://foo.bar/image.jpg!' => '', 'floating !>http://foo.bar/image.jpg!' => - 'floating ', + 'floating ', 'with class !(some-class)http://foo.bar/image.jpg!' => - 'with class ', + 'with class ', 'with class !(wiki-class-foo)http://foo.bar/image.jpg!' => - 'with class ', + 'with class ', 'with style !{width:100px;height:100px}http://foo.bar/image.jpg!' => - 'with style ', + 'with style ', 'with title !http://foo.bar/image.jpg(This is a title)!' => - 'with title This is a title', + 'with title This is a title', 'with title !http://foo.bar/image.jpg(This is a double-quoted "title")!' => 'with title ', + 'alt="This is a double-quoted "title"">', 'with query string !http://foo.bar/image.cgi?a=1&b=2!' => - 'with query string ' + 'with query string ' } with_settings :text_formatting => 'textile' do to_test.each {|text, result| assert_equal "

#{result}

", textilizable(text)} @@ -161,24 +161,24 @@ class ApplicationHelperTest < Redmine::HelperTest p=. !bar.gif! RAW with_settings :text_formatting => 'textile' do - assert textilizable(raw).include?('') - assert textilizable(raw).include?('') + assert textilizable(raw).include?('') + assert textilizable(raw).include?('') end end def test_attached_images to_test = { 'Inline image: !logo.gif!' => - 'Inline image: This is a logo', + 'Inline image: This is a logo', 'Inline image: !logo.GIF!' => - 'Inline image: This is a logo', + 'Inline image: This is a logo', 'Inline WebP image: !logo.webp!' => - 'Inline WebP image: WebP image', - 'No match: !ogo.gif!' => 'No match: ', - 'No match: !ogo.GIF!' => 'No match: ', + 'Inline WebP image: WebP image', + 'No match: !ogo.gif!' => 'No match: ', + 'No match: !ogo.GIF!' => 'No match: ', # link image '!logo.gif!:http://foo.bar/' => - 'This is a logo', + 'This is a logo', } attachments = Attachment.all with_settings :text_formatting => 'textile' do @@ -190,31 +190,31 @@ class ApplicationHelperTest < Redmine::HelperTest attachments = Attachment.all with_settings text_formatting: 'textile' do # When alt text is set - assert_match %r[alt text], + assert_match %r[alt text], textilizable('!logo.gif(alt text)!', attachments: attachments) # When alt text and style are set - assert_match %r[alt text], + assert_match %r[alt text], textilizable('!{width:100px}logo.gif(alt text)!', attachments: attachments) # When alt text is not set - assert_match %r[This is a logo], + assert_match %r[This is a logo], textilizable('!logo.gif!', attachments: attachments) # When alt text is not set and the attachment has no description - assert_match %r[], + assert_match %r[], textilizable('!testfile.PNG!', attachments: attachments) # When no matching attachments are found - assert_match %r[], + assert_match %r[], textilizable('!no-match.jpg!', attachments: attachments) - assert_match %r[alt text], + assert_match %r[alt text], textilizable('!no-match.jpg(alt text)!', attachments: attachments) # When no attachment is registered - assert_match %r[], + assert_match %r[], textilizable('!logo.gif!', attachments: []) - assert_match %r[alt text], + assert_match %r[alt text], textilizable('!logo.gif(alt text)!', attachments: []) end end @@ -232,8 +232,8 @@ class ApplicationHelperTest < Redmine::HelperTest RAW with_settings :text_formatting => 'textile' do - assert textilizable(raw, :object => journal).include?("\"\"") - assert textilizable(raw, :object => journal).include?("\"\"") + assert textilizable(raw, :object => journal).include?("\"\"") + assert textilizable(raw, :object => journal).include?("\"\"") end end @@ -245,7 +245,7 @@ class ApplicationHelperTest < Redmine::HelperTest with_settings :text_formatting => 'textile' do to_test.each do |filename, result| attachment = Attachment.generate!(:filename => filename) - assert_include %(), + assert_include %(), textilizable("!#{filename}!", :attachments => [attachment]) end end @@ -272,7 +272,7 @@ class ApplicationHelperTest < Redmine::HelperTest with_settings :text_formatting => 'textile' do assert_equal( %(

), + %(srcset="/attachments/download/#{attachment.id}/image@2x.png 2x" alt="" loading="lazy">

), textilizable("!image@2x.png!", :attachments => [attachment]) ) end @@ -325,13 +325,13 @@ class ApplicationHelperTest < Redmine::HelperTest to_test = { 'Inline image: !testtest.jpg!' => - 'Inline image: ', + 'Inline image: ', 'Inline image: !testtest.jpeg!' => - 'Inline image: ', + 'Inline image: ', 'Inline image: !testtest.jpe!' => - 'Inline image: ', + 'Inline image: ', 'Inline image: !testtest.bmp!' => - 'Inline image: ', + 'Inline image: ', } attachments = [a1, a2, a3, a4] @@ -356,9 +356,9 @@ class ApplicationHelperTest < Redmine::HelperTest to_test = { 'Inline image: !testfile.png!' => - 'Inline image: ', + 'Inline image: ', 'Inline image: !Testfile.PNG!' => - 'Inline image: ', + 'Inline image: ', } attachments = [a1, a2] with_settings :text_formatting => 'textile' do @@ -378,7 +378,7 @@ class ApplicationHelperTest < Redmine::HelperTest "This is not a \"Link\":\n\nAnother paragraph" => "This is not a \"Link\":

\n\n\n\t

Another paragraph", # no multiline link text "This is a double quote \"on the first line\nand another on a second line\":test" => - "This is a double quote \"on the first line
and another on a second line\":test", + "This is a double quote \"on the first line
and another on a second line\":test", # mailto link "\"system administrator\":mailto:sysadmin@example.com?subject=redmine%20permissions" => "system administrator", @@ -391,7 +391,7 @@ class ApplicationHelperTest < Redmine::HelperTest '(see "inline link":http://www.foo.bar/Test-)' => '(see inline link)', 'http://foo.bar/page?p=1&t=z&s=-' => - 'http://foo.bar/page?p=1&t=z&s=-', + 'http://foo.bar/page?p=1&t=z&s=-', 'This is an intern "link":/foo/bar-' => 'This is an intern link' } with_settings :text_formatting => 'textile' do @@ -1317,10 +1317,10 @@ class ApplicationHelperTest < Redmine::HelperTest def test_html_tags to_test = { "

content
" => "

<div>content</div>

", - "
content
" => "

<div class="bold">content</div>

", + "
content
" => "

<div class=\"bold\">content</div>

", "" => "

<script>some script;</script>

", # do not escape pre/code tags - "
\nline 1\nline2
" => "
\nline 1\nline2
", + "
\nline 1\nline2
" => "
line 1\nline2
", "
\nline 1\nline2
" => "
\nline 1\nline2
", "
content
" => "
<div class=\"foo\">content</div>
", "
content
" => "
<div class=\"<foo\">content</div>
", @@ -1477,7 +1477,7 @@ class ApplicationHelperTest < Redmine::HelperTest "Cell 21#{link3}" @project = Project.find(1) with_settings :text_formatting => 'textile' do - assert_equal "#{result}
", textilizable(text).gsub(/[\t\n]/, '') + assert_equal "#{result}
", textilizable(text).gsub(/[\t\n]/, '') end end @@ -1498,7 +1498,7 @@ class ApplicationHelperTest < Redmine::HelperTest def test_wiki_horizontal_rule with_settings :text_formatting => 'textile' do - assert_equal '
', textilizable('---') + assert_equal '
', textilizable('---') assert_equal '

Dashes: ---

', textilizable('Dashes: ---') end end diff --git a/test/unit/lib/redmine/wiki_formatting/tablesort_scrubber_test.rb b/test/unit/lib/redmine/wiki_formatting/tablesort_scrubber_test.rb new file mode 100644 index 000000000..8144309c0 --- /dev/null +++ b/test/unit/lib/redmine/wiki_formatting/tablesort_scrubber_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# This code is released under the GNU General Public License. + +require_relative '../../../../test_helper' + +class Redmine::WikiFormatting::TablesortScrubberTest < ActiveSupport::TestCase + def filter(html) + fragment = Redmine::WikiFormatting::HtmlParser.parse(html) + scrubber = Redmine::WikiFormatting::TablesortScrubber.new + fragment.scrub!(scrubber) + fragment.to_s + end + + test 'should not add data-controller attribute by default' do + table = <<~HTML + + + + + + + + + + + + + +
AB
+ HTML + assert_equal table, filter(table) + end + + test 'should not add data-controller attribute when the table has less than 3 rows' do + table = <<~HTML + + + + + + + + + +
AB
+ HTML + with_settings :wiki_tablesort_enabled => 1 do + assert_equal table, filter(table) + end + end + + test 'should add data-controller attribute when the table contains at least 3 rows and enables sorting' do + input = <<~HTML + + + + + + + + + + + + + +
AB
+ HTML + expected = <<~HTML + + + + + + + + + + + + + +
AB
+ HTML + with_settings :wiki_tablesort_enabled => 1 do + assert_equal expected, filter(input) + end + end +end diff --git a/test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb b/test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb index 678d4c6b2..dd11ea99e 100644 --- a/test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb +++ b/test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb @@ -252,7 +252,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase expected = <<~EXPECTED

John said:

- Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
+ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.