Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,26 @@ img.avatar {
line-height: 1.9;
}

.markdown-rendered .task-list {
list-style: none;
padding-left: var(--space-md);
}

.markdown-rendered .task-list-item label {
display: flex;
align-items: baseline;
gap: var(--space-sm);
cursor: pointer;
}

.markdown-rendered .task-list-item input[type="checkbox"] {
cursor: pointer;
flex-shrink: 0;
margin: 0;
position: relative;
top: 1px;
}

.markdown-rendered pre {
background: #f6f8fa;
border: 1px solid var(--color-border);
Expand Down
60 changes: 59 additions & 1 deletion engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module CoPlan
class PlansController < ApplicationController
before_action :set_plan, only: [:show, :edit, :update, :update_status]
before_action :set_plan, only: [:show, :edit, :update, :update_status, :toggle_checkbox]

def index
@plans = Plan.includes(:plan_type, :tags).order(updated_at: :desc)
Expand Down Expand Up @@ -51,6 +51,64 @@ def update_status
end
end

def toggle_checkbox
authorize!(@plan, :show?)

old_text = params[:old_text]
new_text = params[:new_text]
base_revision = params[:base_revision]&.to_i

unless old_text.present? && new_text.present? && base_revision.present?
render json: { error: "old_text, new_text, and base_revision are required" }, status: :unprocessable_content
return
end

checkbox_pattern = /\A\s*[*+-]\s+\[[ xX]\]\s/
unless old_text.match?(checkbox_pattern) && new_text.match?(checkbox_pattern)
render json: { error: "old_text and new_text must be task list items" }, status: :unprocessable_content
return
end

ActiveRecord::Base.transaction do
@plan.lock!
@plan.reload

if @plan.current_revision != base_revision
render json: { error: "Conflict", current_revision: @plan.current_revision }, status: :conflict
return
end

current_content = @plan.current_content || ""
result = Plans::ApplyOperations.call(
content: current_content,
operations: [{ "op" => "replace_exact", "old_text" => old_text, "new_text" => new_text }]
)
Comment on lines +82 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate toggle payload before applying replace_exact

toggle_checkbox applies client-supplied old_text/new_text directly as a replace_exact operation, so any user who can hit this endpoint can replace arbitrary unique text in the plan by crafting the JSON payload instead of only flipping [ ]/[x] on a task item. This effectively bypasses the normal update? guard for non-checkbox content and turns a checkbox toggle API into a general edit primitive.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 26dd248 — added server-side validation that both old_text and new_text match the task list pattern (/\A\s*[*+-]\s+\[[ xX]\]\s/). Arbitrary text replacements now get a 422.


new_revision = @plan.current_revision + 1
diff = Diffy::Diff.new(current_content, result[:content]).to_s

version = PlanVersion.create!(
plan: @plan,
revision: new_revision,
content_markdown: result[:content],
actor_type: "human",
actor_id: current_user.id,
change_summary: "Toggle checkbox",
diff_unified: diff.presence,
operations_json: result[:applied],
base_revision: base_revision
)

@plan.update!(current_plan_version: version, current_revision: new_revision)
@plan.comment_threads.mark_out_of_date_for_new_version!(version)
end

broadcast_plan_update(@plan)
render json: { revision: @plan.current_revision }
rescue Plans::OperationError => e
render json: { error: e.message }, status: :unprocessable_content
end

private

def set_plan
Expand Down
56 changes: 53 additions & 3 deletions engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ module MarkdownHelper
ul ol li
table thead tbody tfoot tr th td
pre code
a img
a img input label
strong em b i u s del
blockquote hr br
dd dt dl
sup sub
details summary
].freeze

ALLOWED_ATTRIBUTES = %w[id class href src alt title].freeze
ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target].freeze

def render_markdown(content)
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil })
sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
tag.div(sanitized, class: "markdown-rendered")
interactive = make_checkboxes_interactive(sanitized, content)
tag.div(interactive.html_safe, class: "markdown-rendered")
end

def markdown_to_plain_text(content)
Expand All @@ -38,5 +39,54 @@ def render_line_view(content)

tag.div(safe_join(line_divs), class: "line-view", data: { controller: "line-selection" })
end

private

def make_checkboxes_interactive(html, content)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
checkboxes = doc.css('input[type="checkbox"]')
return html if checkboxes.empty?

task_lines = extract_task_lines(content)

checkboxes.each_with_index do |cb, i|
line_text = task_lines[i]
next unless line_text

cb.remove_attribute("disabled")
cb["data-action"] = "coplan--checkbox#toggle"
cb["data-coplan--checkbox-target"] = "checkbox"
cb["data-line-text"] = line_text

li = cb.parent
next unless li&.name == "li"
li.add_class("task-list-item")

# Wrap li contents in a <label> so the whole text is clickable
label = Nokogiri::XML::Node.new("label", doc)
li.children.each { |child| label.add_child(child) }
li.add_child(label)

ul = li.parent
ul.add_class("task-list") if ul&.name == "ul"
end

doc.to_html
end

def extract_task_lines(content)
lines = []
in_fence = false
content.to_s.each_line do |line|
stripped = line.rstrip
if stripped.match?(/\A(`{3,}|~{3,})/)
in_fence = !in_fence
next
end
next if in_fence
lines << stripped if stripped.match?(/^\s*[*+-]\s+\[[ xX]\]\s/)
end
lines
end
end
end
68 changes: 68 additions & 0 deletions engine/app/javascript/controllers/coplan/checkbox_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static values = { planId: String, revision: Number, toggleUrl: String }
static targets = ["checkbox"]

toggle(event) {
const checkbox = event.target
const lineText = checkbox.dataset.lineText
if (!lineText) return
if (this.inflight) { checkbox.checked = !checkbox.checked; return }

const nowChecked = checkbox.checked
const oldText = lineText
const newText = nowChecked
? lineText.replace(/([*+-]\s+)\[[ ]\]/, "$1[x]")
: lineText.replace(/([*+-]\s+)\[[xX]\]/, "$1[ ]")

// Optimistic UI: update immediately
checkbox.dataset.lineText = newText

this.inflight = true
this.#sendToggle({ checkbox, oldText, newText, nowChecked, retried: false })
}

#sendToggle({ checkbox, oldText, newText, nowChecked, retried }) {
const token = document.querySelector('meta[name="csrf-token"]')?.content

fetch(this.toggleUrlValue, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token,
"Accept": "application/json"
},
body: JSON.stringify({
old_text: oldText,
new_text: newText,
base_revision: this.revisionValue
})
}).then(response => {
if (response.ok) {
return response.json().then(data => {
this.revisionValue = data.revision
this.inflight = false
})
} else if (response.status === 409 && !retried) {
// Conflict: someone else edited. Update revision and retry once.
return response.json().then(data => {
if (data.current_revision) {
this.revisionValue = data.current_revision
}
this.#sendToggle({ checkbox, oldText, newText, nowChecked, retried: true })
})
} else {
this.#revert(checkbox, oldText, nowChecked)
}
}).catch(() => {
this.#revert(checkbox, oldText, nowChecked)
})
}

#revert(checkbox, oldText, nowChecked) {
checkbox.checked = !nowChecked
checkbox.dataset.lineText = oldText
this.inflight = false
}
}
2 changes: 1 addition & 1 deletion engine/app/views/coplan/plans/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<div class="plan-content card">
<% if @plan.current_content.present? %>
<div class="plan-layout" data-controller="coplan--text-selection coplan--content-nav" data-coplan--text-selection-plan-id-value="<%= @plan.id %>" data-coplan--text-selection-focus-thread-value="<%= params[:thread] %>" data-action="keydown.esc@document->coplan--text-selection#dismiss">
<div class="plan-layout" data-controller="coplan--text-selection coplan--content-nav coplan--checkbox" data-coplan--text-selection-plan-id-value="<%= @plan.id %>" data-coplan--text-selection-focus-thread-value="<%= params[:thread] %>" data-action="keydown.esc@document->coplan--text-selection#dismiss" data-coplan--checkbox-plan-id-value="<%= @plan.id %>" data-coplan--checkbox-revision-value="<%= @plan.current_revision %>" data-coplan--checkbox-toggle-url-value="<%= toggle_checkbox_plan_path(@plan) %>">
<nav class="content-nav" data-coplan--content-nav-target="sidebar" aria-label="Document outline">
<div class="content-nav__header">
<span class="content-nav__title">Contents</span>
Expand Down
1 change: 1 addition & 0 deletions engine/config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
CoPlan::Engine.routes.draw do
resources :plans, only: [:index, :show, :edit, :update] do
patch :update_status, on: :member
patch :toggle_checkbox, on: :member
resources :versions, controller: "plan_versions", only: [:index, :show]
resources :automated_reviews, only: [:create]
resources :comment_threads, only: [:create] do
Expand Down
99 changes: 99 additions & 0 deletions spec/helpers/coplan/markdown_helper_checkbox_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
require "rails_helper"

RSpec.describe CoPlan::MarkdownHelper, type: :helper do
before do
helper.extend(CoPlan::MarkdownHelper)
end

describe "#render_markdown with task lists" do
it "renders unchecked task items as interactive checkboxes" do
html = helper.render_markdown("- [ ] Buy milk")
expect(html).to include('type="checkbox"')
expect(html).not_to include("disabled")
expect(html).to include('data-action="coplan--checkbox#toggle"')
expect(html).to include('data-coplan--checkbox-target="checkbox"')
end

it "renders checked task items as checked interactive checkboxes" do
html = helper.render_markdown("- [x] Buy milk")
expect(html).to include('type="checkbox"')
expect(html).to include("checked")
expect(html).not_to include("disabled")
end

it "sets data-line-text to the original markdown line" do
html = helper.render_markdown("- [ ] Buy milk")
expect(html).to include('data-line-text="- [ ] Buy milk"')
end

it "sets data-line-text for checked items" do
html = helper.render_markdown("- [x] Done task")
expect(html).to include('data-line-text="- [x] Done task"')
end

it "adds task-list class to parent ul" do
html = helper.render_markdown("- [ ] Item one\n- [x] Item two")
expect(html).to include('class="task-list"')
end

it "adds task-list-item class to li elements" do
html = helper.render_markdown("- [ ] Item")
expect(html).to include("task-list-item")
end

it "wraps checkbox li contents in a label for clickability" do
html = helper.render_markdown("- [ ] Click me")
doc = Nokogiri::HTML::DocumentFragment.parse(html)
label = doc.at_css("li.task-list-item label")
expect(label).to be_present
expect(label.at_css('input[type="checkbox"]')).to be_present
end

it "handles mixed task and non-task items" do
md = "- [ ] Task item\n- Regular item"
html = helper.render_markdown(md)
expect(html).to include('type="checkbox"')
expect(html).to include("Regular item")
end

it "handles multiple task lists" do
md = "- [ ] First\n- [x] Second\n\nSome text\n\n- [ ] Third"
html = helper.render_markdown(md)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
checkboxes = doc.css('input[type="checkbox"]')
expect(checkboxes.length).to eq(3)
expect(checkboxes[0]["data-line-text"]).to eq("- [ ] First")
expect(checkboxes[1]["data-line-text"]).to eq("- [x] Second")
expect(checkboxes[2]["data-line-text"]).to eq("- [ ] Third")
end

it "does not affect regular lists" do
html = helper.render_markdown("- Item one\n- Item two")
expect(html).not_to include('type="checkbox"')
expect(html).not_to include("task-list")
end

it "preserves markdown-rendered wrapper class" do
html = helper.render_markdown("- [ ] Item")
expect(html).to include('class="markdown-rendered"')
end

it "ignores task lines inside fenced code blocks" do
md = "```\n- [ ] Fake checkbox\n```\n\n- [ ] Real checkbox"
html = helper.render_markdown(md)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
checkboxes = doc.css('input[type="checkbox"]')
expect(checkboxes.length).to eq(1)
expect(checkboxes[0]["data-line-text"]).to eq("- [ ] Real checkbox")
end

it "preserves indentation in data-line-text for nested tasks" do
md = "- [ ] Parent\n - [ ] Nested child"
html = helper.render_markdown(md)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
checkboxes = doc.css('input[type="checkbox"]')
nested = checkboxes.find { |cb| cb["data-line-text"]&.include?("Nested") }
expect(nested["data-line-text"]).to eq(" - [ ] Nested child")
end
end
end
2 changes: 1 addition & 1 deletion spec/requests/plans_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
it "show plan wires up both text-selection and content-nav controllers" do
get plan_path(plan)
expect(response).to have_http_status(:success)
expect(response.body).to include('data-controller="coplan--text-selection coplan--content-nav"')
expect(response.body).to include('data-controller="coplan--text-selection coplan--content-nav coplan--checkbox"')
end

it "show plan shares content target between controllers" do
Expand Down
Loading
Loading