Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2763d39
[WIP] feat: reversibility tools
moustachu May 6, 2026
deb656b
[WIP] feat: reversibility content tree
moustachu May 26, 2026
b963a48
Merge branch 'develop' into feat/reversibility
moustachu May 27, 2026
de187a2
[WIP] fix: lint code
moustachu May 28, 2026
2697c0c
[WIP] feat: reversibility content bundle export
moustachu Jun 3, 2026
d6408ca
[WIP] fix: export bundle manifest needs dup
moustachu Jun 3, 2026
7d0e705
[WIP] feat(reversibility): add to bundle debates and proposals (+ com…
moustachu Jun 9, 2026
b33cd2b
Fix i18n-tasks locale glob for nested locale files
Copilot Jun 9, 2026
cfe2094
[WIP] fix(reversibility): bundle loop error when component list is empty
moustachu Jun 9, 2026
c34cace
[WIP] fix(reversibility): commentable UID
moustachu Jun 9, 2026
5bc4f49
[WIP] feat(reversibility): export url with processes, groups, comment…
moustachu Jun 22, 2026
fc7e93a
[WIP] fix(reversibility): export content tree with components as chil…
moustachu Jun 22, 2026
b4b7a83
[WIP] feat(reversibility): add surveys answers to export bundle
moustachu Jun 24, 2026
2d1db74
[WIP] fix(reversibility): convert surveys specific_data to uid
moustachu Jun 24, 2026
196c841
[WIP] feat(reversibility): add proposals votes / notes / endrosements…
moustachu Jun 25, 2026
432c373
[WIP] fix(reversibility): misspelled column name
moustachu Jun 25, 2026
3d18e4e
[WIP] fix(reversibility): safeguard condition for missing id on uid m…
moustachu Jun 25, 2026
9a97004
[WIP] feat(reversibility): add component resource cache for filtering…
moustachu Jun 30, 2026
1f62d26
[WIP] feat(reversibility): add accountability to bundle + component r…
moustachu Jul 1, 2026
a473f47
[WIP] fix(reversibility): docker build warning on node 20
moustachu Jul 1, 2026
2885073
[WIP] feat(reversibility): add assemblies (participatory spaces) to b…
moustachu Jul 2, 2026
f6d570c
[WIP] feat(reversibility): add budget component to bundle
moustachu Jul 5, 2026
561ae97
[WIP] feat(reversibility): add comment votes (up/down) to bundle
moustachu Jul 5, 2026
8eed683
[WIP] feat(reversibility): add organization stats to content tree header
moustachu Jul 9, 2026
50a5f69
[WIP] feat(reversibility): add organization stats to content tree header
moustachu Jul 9, 2026
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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ENV RAILS_ENV=production \
BUNDLE_JOBS=8 \
MAKEFLAGS="-j8" \
NODE_OPTIONS="--max-old-space-size=4096" \
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true \
EXECJS_RUNTIME=Node

WORKDIR /opt/decidim
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ DECIDIM_TAG = "v0.29.5"

gem "decidim", github: "decidim/decidim", tag: DECIDIM_TAG
gem "decidim-conferences", github: "decidim/decidim", tag: DECIDIM_TAG
gem "decidim-design", github: "decidim/decidim", tag: DECIDIM_TAG
gem "decidim-initiatives", github: "decidim/decidim", tag: DECIDIM_TAG
gem "decidim-templates", github: "decidim/decidim", tag: DECIDIM_TAG

Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ GIT
decidim-debates (0.29.5)
decidim-comments (= 0.29.5)
decidim-core (= 0.29.5)
decidim-design (0.29.5)
decidim-core (= 0.29.5)
decidim-dev (0.29.5)
bullet (~> 7.1.6)
byebug (~> 11.0)
Expand Down Expand Up @@ -1151,6 +1153,7 @@ DEPENDENCIES
decidim-conferences!
decidim-dataspace!
decidim-decidim_awesome!
decidim-design!
decidim-dev!
decidim-emitter!
decidim-extra_user_fields!
Expand Down
29 changes: 29 additions & 0 deletions app/controllers/decidim/admin/content/bundle_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

module Decidim
module Admin
module Content
class BundleController < Decidim::Admin::ApplicationController
before_action :default_permissions

def export
zip_data = Decidim::Content::CsvBundler.new(organization: current_organization).export_to_archive

respond_to do |format|
format.zip do
send_data zip_data.read,
filename: "#{current_organization.host}--content-bundle--#{Time.zone.now.strftime("%Y%m%d-%H%M%S")}.zip",
type: "application/zip"
end
end
end

private

def default_permissions
enforce_permission_to :update, :organization, organization: current_organization
end
end
end
end
end
75 changes: 75 additions & 0 deletions app/controllers/decidim/admin/content/tree_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

module Decidim
module Admin
module Content
class TreeController < Decidim::Admin::ApplicationController
helper Decidim::IconHelper

before_action :default_permissions
before_action :relative_view_path
helper_method :content_tree, :any_grand_children?
layout "decidim/admin/content/tree"

def index; end

def export
generator = Decidim::Content::TreeGenerator.new(
organization: current_organization,
include_object: false,
location_type: :url,
include_metadata: true,
include_score: true,
components_as_children: true
)
csv_data = generator.to_csv

respond_to do |format|
format.csv do
send_data csv_data.string,
filename: "content-tree--#{Time.zone.now.strftime("%Y%m%d-%H%M%S")}.csv",
type: "text/csv"
end
end
end

def treemap
respond_to do |format|
format.html
format.json { render json: content_treemap }
end
end

def table; end

private

def default_permissions
enforce_permission_to :update, :organization, organization: current_organization
end

def relative_view_path
prepend_view_path "app/views/decidim/admin/content/tree"
end

def content_tree
@content_tree ||= Decidim::Content::TreeGenerator.new(organization: current_organization).hash
end

def content_treemap
Decidim::Content::TreeGenerator.new(
organization: current_organization,
include_object: false,
include_metadata: false,
include_score: true,
components_as_children: true
).hash
end

def any_grand_children?(node)
node[:children]&.any? { |child| child[:children]&.any? }
end
end
end
end
end
16 changes: 16 additions & 0 deletions app/jobs/dummy_long_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class DummyLongJob < ApplicationJob
def perform(duration: 60.seconds, step: 5.seconds)
elapsed = 0
total_steps = (duration / step).to_i
Rails.logger.info "Starting DummyLongJob for #{duration} seconds with #{total_steps} steps of #{step} seconds each."
step_index = 1
while elapsed < duration
Rails.logger.info "DummyLongJob progress: Step #{step_index}/#{total_steps} (#{elapsed}/#{duration} seconds)"
sleep(step)
elapsed += step.seconds
step_index += 1
end
end
end
1 change: 1 addition & 0 deletions app/packs/entrypoints/decidim_admin_content_tree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "src/decidim/admin/content/tree";
3 changes: 3 additions & 0 deletions app/packs/entrypoints/decidim_admin_content_tree.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@import "stylesheets/decidim/admin/content/utilities.scss";
@import "stylesheets/decidim/admin/content/tree.scss";
@import "stylesheets/decidim/admin/content/table.scss";
1 change: 1 addition & 0 deletions app/packs/entrypoints/decidim_admin_content_treemap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "src/decidim/admin/content/tree/treemap";
Empty file.
135 changes: 135 additions & 0 deletions app/packs/src/decidim/admin/content/tree/treemap/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import * as d3 from "d3";

const container = document.getElementById("treemap-container");

if (container) {
d3.json(container.dataset.src).then(data => {

// Specify the chart’s dimensions.
const width = 1280;
const height = 924;

// This custom tiling function adapts the built-in binary tiling function
// for the appropriate aspect ratio when the treemap is zoomed-in.
function tile(node, x0, y0, x1, y1) {
d3.treemapBinary(node, 0, 0, width, height);
for (const child of node.children) {
child.x0 = x0 + child.x0 / width * (x1 - x0);
child.x1 = x0 + child.x1 / width * (x1 - x0);
child.y0 = y0 + child.y0 / height * (y1 - y0);
child.y1 = y0 + child.y1 / height * (y1 - y0);
}
}

// Create the scales.
const x = d3.scaleLinear().rangeRound([0, width]);
const y = d3.scaleLinear().rangeRound([0, height]);

// Formatting utilities.
const format = d3.format(",d");
const name = d => d.ancestors().reverse().map(d => d.data.name).join("/");

// Create the SVG container.
const svg = d3.create("svg")
.attr("viewBox", [0.5, -30.5, width, height + 30])
.attr("width", width)
.attr("height", height + 30)
.attr("style", "max-width: 100%; height: auto;")
.style("font", "10px sans-serif");

function render(group, root) {
const node = group
.selectAll("g")
.data(root.children.concat(root))
.join("g");

node.filter(d => d === root ? d.parent : d.children)
.attr("cursor", "pointer")
.on("click", (event, d) => d === root ? zoomout(root) : zoomin(d));

node.append("title")
.text(d => `${name(d)}\n${format(d.value)}`);

node.append("rect")
.attr("id", d => (d.leafUid = crypto.randomUUID("leaf")).id)
.attr("fill", d => d === root ? "#fff" : d.children ? "#ccc" : "#ddd")
.attr("stroke", "#fff");

node.append("clipPath")
.attr("id", d => (d.clipUid = crypto.randomUUID("clip")).id)
.append("use")
.attr("xlink:href", d => d.leafUid.href);

node.append("text")
.attr("clip-path", d => d.clipUid)
.attr("font-weight", d => d === root ? "bold" : null)
.selectAll("tspan")
.data(d => (d === root ? name(d) : d.data.name).split(/(?=[A-Z][^A-Z])/g).concat(format(d.value)))
.join("tspan")
.attr("x", 3)
.attr("y", (d, i, nodes) => `${(i === nodes.length - 1) * 0.3 + 1.1 + i * 0.9}em`)
.attr("fill-opacity", (d, i, nodes) => i === nodes.length - 1 ? 0.7 : null)
.attr("font-weight", (d, i, nodes) => i === nodes.length - 1 ? "normal" : null)
.text(d => d);

group.call(position, root);
}

function position(group, root) {
group.selectAll("g")
.attr("transform", d => d === root ? `translate(0,-30)` : `translate(${x(d.x0)},${y(d.y0)})`)
.select("rect")
.attr("width", d => d === root ? width : x(d.x1) - x(d.x0))
.attr("height", d => d === root ? 30 : y(d.y1) - y(d.y0));
}

// When zooming in, draw the new nodes on top, and fade them in.
function zoomin(d) {
const group0 = group.attr("pointer-events", "none");
const group1 = group = svg.append("g").call(render, d);

x.domain([d.x0, d.x1]);
y.domain([d.y0, d.y1]);

svg.transition()
.duration(750)
.call(t => group0.transition(t).remove()
.call(position, d.parent))
.call(t => group1.transition(t)
.attrTween("opacity", () => d3.interpolate(0, 1))
.call(position, d));
}

// When zooming out, draw the old nodes on top, and fade them out.
function zoomout(d) {
const group0 = group.attr("pointer-events", "none");
const group1 = group = svg.insert("g", "*").call(render, d.parent);

x.domain([d.parent.x0, d.parent.x1]);
y.domain([d.parent.y0, d.parent.y1]);

svg.transition()
.duration(750)
.call(t => group0.transition(t).remove()
.attrTween("opacity", () => d3.interpolate(1, 0))
.call(position, d))
.call(t => group1.transition(t)
.call(position, d.parent));
}

// Compute the layout.
const hierarchy = d3.hierarchy(data)
.sum(d => d.value)
.sort((a, b) => b.value - a.value);
const root = d3.treemap().tile(tile).size([width, height])(hierarchy);

// Display the root.
let group = svg.append("g")
.call(render, root);

// Append the SVG element to the container.
document.getElementById("treemap-container").appendChild(svg.node());
});
}


Loading
Loading