Feature #39130 » 0001-WIP.patch
| 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" |