Skip to content
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ AllCops:
- "node_modules/**/*"
- "db/schema.rb"
- "vendor/**/*"

RSpec/DescribeClass:
Exclude:
- "spec/system/**/*"
- spec/i18n_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

module Decidim
module ExtraUserFields
module Admin
class InsightsController < Decidim::Admin::ApplicationController
include Decidim::Admin::ParticipatorySpaceAdminContext
helper InsightsHelper
layout :layout

helper_method :pivot_table_presenter, :current_metric, :current_row_field, :current_col_field,
:available_metrics, :available_fields

before_action :set_breadcrumbs

def show
enforce_permission_to :read, :insights
end

private

def pivot_table_presenter
@pivot_table_presenter ||= PivotTablePresenter.new(
PivotTableBuilder.new(
participatory_space: current_participatory_space,
metric_name: current_metric,
row_field: current_row_field,
col_field: current_col_field
).call
)
end

def current_metric
@current_metric ||= detect_metric(params[:metric]) || available_metrics.first
end

def current_row_field
@current_row_field ||= detect_field(params[:rows]) || available_fields.first
end

def current_col_field
@current_col_field ||= detect_field(params[:cols]) || available_fields.second
end

def available_metrics
@available_metrics ||= InsightMetrics.available_metrics
end

def available_fields
@available_fields ||= Decidim::ExtraUserFields.insight_fields
end

def detect_metric(name)
name = name.to_s
name if InsightMetrics.valid_metric?(name)
end

def detect_field(name)
name = name.to_s
name if available_fields.include?(name)
end

def permission_class_chain
[::Decidim::ExtraUserFields::Admin::Permissions] + super
end

def current_participatory_space
@current_participatory_space ||= if params[:participatory_process_slug]
Decidim::ParticipatoryProcess.find_by!(organization: current_organization, slug: params[:participatory_process_slug])
else
Decidim::Assembly.find_by!(organization: current_organization, slug: params[:assembly_slug])
end
end

def set_breadcrumbs
if params[:participatory_process_slug]
secondary_breadcrumb_menus << :admin_participatory_process_menu
elsif params[:assembly_slug]
secondary_breadcrumb_menus << :admin_assembly_menu
end
end
end
end
end
end
64 changes: 64 additions & 0 deletions app/helpers/decidim/extra_user_fields/admin/insights_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module Decidim
module ExtraUserFields
module Admin
module InsightsHelper
# Renders a labeled <select> that auto-submits on change.
# Yields each option to the block for label generation.
def insight_selector_field(param_name, options, selected_value, &block)
label_text = t("decidim.admin.extra_user_fields.insights.selectors.#{param_name}")

content_tag(:div, class: "insights-selectors__field") do
label_tag(param_name, label_text, class: "insights-selectors__label") +
select_tag(
param_name,
options_for_select(options.map { |opt| [block.call(opt), opt] }, selected_value),
class: "insights-selectors__select",
onchange: "this.form.submit();"
)
end
end

def metric_label(metric_name)
t("decidim.admin.extra_user_fields.insights.metrics.#{metric_name}",
default: metric_name.humanize)
end

def field_label(field_name)
t("decidim.admin.extra_user_fields.insights.fields.#{field_name}",
default: field_name.humanize)
end

# Tries field-specific i18n keys first (e.g., genders.female), falls back to humanize.
def field_value_label(field_name, value)
return t("decidim.admin.extra_user_fields.insights.non_specified") if value.nil?
return country_name_from_code(value) if field_name.to_s == "country"

key = i18n_key_for_field_value(field_name, value)
t(key, default: value.humanize)
end

private

def i18n_key_for_field_value(field_name, value)
case field_name.to_s
when "gender"
"decidim.extra_user_fields.genders.#{value}"
when "age_range"
"decidim.extra_user_fields.age_ranges.#{value}"
else
"decidim.admin.extra_user_fields.insights.field_values.#{field_name}.#{value}"
end
end

def country_name_from_code(value)
country = ISO3166::Country[value.to_s.strip.upcase]
return value.to_s.humanize unless country

country.translations[I18n.locale.to_s] || country.common_name || country.iso_short_name
end
end
end
end
end
83 changes: 83 additions & 0 deletions app/models/decidim/extra_user_fields/pivot_table.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

module Decidim
module ExtraUserFields
# Value object representing a cross-tabulation (pivot) table.
# Holds row values, column values, a 2D cell hash, and computed totals.
class PivotTable
attr_reader :row_values, :col_values, :cells

# @param row_values [Array<String>] sorted unique values for the row axis
# @param col_values [Array<String>] sorted unique values for the column axis
# @param cells [Hash{String => Hash{String => Integer}}] cells[row][col] = count
def initialize(row_values:, col_values:, cells:)
@row_values = row_values
@col_values = col_values
@cells = cells
end

def cell(row, col)
cells.dig(row, col) || 0
end

def row_total(row)
row_totals[row]
end

def col_total(col)
col_totals[col]
end

def grand_total
@grand_total ||= row_totals.values.sum
end

def row_totals
@row_totals ||= row_values.index_with do |row|
col_values.sum { |col| cell(row, col) }
end
end

def col_totals
@col_totals ||= col_values.index_with do |col|
row_values.sum { |row| cell(row, col) }
end
end

def max_value
@max_value ||= cells.values.flat_map(&:values).max || 0
end

def empty?
grand_total.zero?
end

# Min-max among cells where both row and col are non-nil (specified).
def specified_cell_range
@specified_cell_range ||= positive_minmax(
row_values.compact.flat_map { |row| col_values.compact.map { |col| cell(row, col) } }
)
end

# Min-max among all cells (including nil row/col).
def all_cell_range
@all_cell_range ||= positive_minmax(cells.values.flat_map(&:values))
end

def row_total_max
@row_total_max ||= row_values.map { |row| row_total(row) }.max || 0
end

def col_total_max
@col_total_max ||= col_values.map { |col| col_total(col) }.max || 0
end

private

def positive_minmax(values)
non_zero = values.select(&:positive?)
non_zero.empty? ? [0, 0] : non_zero.minmax
end
end
end
end
1 change: 1 addition & 0 deletions app/packs/entrypoints/decidim_extra_user_fields.scss
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
@import "stylesheets/decidim/extra_user_fields/signup_form";
@import "stylesheets/decidim/extra_user_fields/insights";
Loading