Project

General

Profile

Feature #39130 » 0001-WIP.patch

Marius BĂLTEANU, 2025-07-28 22:55

View differences:

app/controllers/issues_controller.rb
198 198
  def update
199 199
    return unless update_issue_from_params
200 200

  
201
    if params[:sourcepos].present?
202
      @issue.update_task_list_item(:description, params[:sourcepos], params[:checked])
203
    end
204

  
201 205
    attachments = params[:attachments] || params.dig(:issue, :uploads)
202 206
    if @issue.attachments_addable?
203 207
      @issue.save_attachments(attachments)
app/controllers/wiki_controller.rb
172 172
      @section = params[:section].to_i
173 173
      @section_hash = params[:section_hash]
174 174
      @content.text = Redmine::WikiFormatting.formatter.new(@content.text).update_section(@section, @text, @section_hash)
175
    elsif params[:sourcepos].present?
176
      @content.update_task_list_item(:text, params[:sourcepos], params[:checked])
175 177
    else
176 178
      @content.version = content_params[:version] if content_params[:version]
177 179
      @content.text = @text
app/javascript/controllers/task_list_controller.js
1
import {Controller} from '@hotwired/stimulus'
2

  
3
export default class extends Controller {
4
    // Define the 'updateUrl' value that we passed from the HTML
5
    static values = {
6
        updateUrl: String,
7
    };
8

  
9
    connect() {
10
        // Find all checkboxes within this controller's scope and enable them.
11
        this.element.querySelectorAll('input[type="checkbox"].task-list-item-checkbox').forEach(checkbox => {
12
            checkbox.disabled = false;
13

  
14
            checkbox.addEventListener('change', (event) => {
15
                const isChecked = event.target.checked;
16
                const sourcePosition = checkbox.parentElement.dataset.sourcepos;
17

  
18
                if (!sourcePosition) {
19
                    console.error('Task item is missing data-sourcepos attribute.');
20
                    // Optionally revert the checkbox and show an error
21
                    event.target.checked = !isChecked;
22
                    return;
23
                }
24

  
25
                this.updateTaskListItem(this.updateUrlValue, sourcePosition, isChecked);
26
            });
27
        });
28
    }
29

  
30
    async updateTaskListItem(updateUrl, sourcePosition, isChecked) {
31

  
32
        // You need to get the CSRF token for Rails
33
        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
34

  
35
        try {
36
            const response = await fetch(updateUrl, {
37
                method: 'PATCH',
38
                headers: {
39
                    'Content-Type': 'application/json',
40
                    'Accept': 'application/json',
41
                    'X-CSRF-Token': csrfToken,
42
                },
43
                body: JSON.stringify({
44
                    sourcepos: sourcePosition,
45
                    checked: isChecked,
46
                }),
47
            });
48

  
49
            if (!response.ok) {
50
                // Handle server error: revert the checkbox state and alert the user
51
                throw new Error();
52
            }
53

  
54
            console.log('Task updated successfully!');
55

  
56
        } catch (error) {
57
            console.error('Error:', error);
58
            // Revert the checkbox state
59
        }
60
    }
61
}
62

  
app/models/concerns/task_list_updatable.rb
1
# app/models/concerns/task_list_updatable.rb
2
module TaskListUpdatable
3
  extend ActiveSupport::Concern
4

  
5
  # Updates a task list item in a text attribute based on its position.
6
  # This method modifies the attribute on the object in memory; it does not save the record.
7
  #
8
  # @param attribute_name [Symbol] The name of the text attribute (e.g., :description).
9
  # @param source_pos [String] The position of the task item, e.g., "10:1-10:15".
10
  # @param checked_value [Object] The value from params indicating the new checked state.
11
  def update_task_list_item(attribute_name, source_pos, checked_value)
12
    text = public_send(attribute_name)
13

  
14
    Rails.logger.debug "*****"*88
15
    Rails.logger.debug text.inspect
16
    return if text.blank? || source_pos.blank?
17

  
18
    # The line number from sourcepos is 1-based.
19
    line_number = source_pos.to_s.split(':').first.to_i
20
    return if line_number <= 0
21

  
22
    lines = text.lines
23
    # The lines array is 0-based, so we subtract 1.
24
    target_line = lines[line_number - 1]
25
    return unless target_line
26

  
27
    # Robustly determine the checked state from params.
28
    is_checked = [true, 'true', '1'].include?(checked_value)
29

  
30
    if is_checked
31
      # Change first '[ ]' to '[x]' on the line.
32
      target_line.sub!(/\[\s\]/, '[x]')
33
    else
34
      # Change first '[x]' or '[X]' to '[ ]' on the line.
35
      target_line.sub!(/\[x\]/i, '[ ]')
36
    end
37

  
38
    public_send(:"#{attribute_name}=", lines.join)
39
  end
40
end
app/models/issue.rb
26 26
  before_save :set_parent_id
27 27
  include Redmine::NestedSet::IssueNestedSet
28 28
  include Redmine::Reaction::Reactable
29
  include TaskListUpdatable
29 30

  
30 31
  belongs_to :project
31 32
  belongs_to :tracker
app/models/wiki_content.rb
21 21

  
22 22
class WikiContent < ApplicationRecord
23 23
  self.locking_column = 'version'
24

  
25
  include TaskListUpdatable
26

  
24 27
  belongs_to :page, :class_name => 'WikiPage'
25 28
  belongs_to :author, :class_name => 'User'
26 29
  has_many :versions, :class_name => 'WikiContentVersion', :dependent => :delete_all
app/views/issues/show.html.erb
98 98
  </div>
99 99

  
100 100
  <p><strong><%=l(:field_description)%></strong></p>
101
  <div id="issue_description_wiki" class="wiki" data-quote-reply-target="content">
101
  <div id="issue_description_wiki" class="wiki"
102
       data-quote-reply-target="content"
103
       data-controller="task-list"
104
       data-task-list-id-value="<%= edit_issue_path(@issue.id) %>">
102 105
  <%= textilizable @issue, :description, :attachments => @issue.attachments %>
103 106
  </div>
104 107
</div>
app/views/wiki/_content.html.erb
1
<div class="wiki wiki-page">
1
<div class="wiki wiki-page"
2
     data-controller="task-list"
3
     data-task-list-update-url-value="<%= project_wiki_page_path(:id => @page.title) %>">
2 4
  <%= textilizable content, :text, :attachments => content.page.attachments,
3 5
        :edit_section_links => (@sections_editable && {:controller => 'wiki', :action => 'edit', :project_id => @page.project, :id => @page.title}) %>
4 6
</div>
lib/redmine/wiki_formatting/common_mark/formatter.rb
47 47
          github_pre_lang: false,
48 48
          hardbreaks: Redmine::Configuration['common_mark_enable_hardbreaks'] == true,
49 49
          tasklist_classes: true,
50
          sourcepos: true,
50 51
        }.freeze,
51 52
        commonmarker_plugins: {
52 53
          syntax_highlighter: nil
lib/redmine/wiki_formatting/common_mark/markdown_filter.rb
33 33

  
34 34
        def call
35 35
          html = Commonmarker.to_html(@text, options: {
36
                                        extension: extensions,
36
            extension: extensions,
37 37
            render: render_options,
38 38
            parse: parse_options
39
                                      }, plugins: plugins)
39
          }, plugins: plugins)
40 40

  
41 41
          html.rstrip!
42 42
          html
lib/redmine/wiki_formatting/common_mark/sanitization_filter.rb
112 112

  
113 113
          # allow `id` in li element for footnotes
114 114
          # allow `class` in li element for task list items
115
          allowlist[:attributes]["li"] = %w(id class)
115
          allowlist[:attributes]["li"] = %w(id class data-sourcepos)
116 116
          allowlist[:transformers].push lambda{|env|
117 117
            node = env[:node]
118 118
            return unless node.name == "li"
    (1-1/1)