Merge pull request #3907 from consul/polygon_geographies

Add polygon geographies to Budgets' map
This commit is contained in:
Javi Martín
2023-05-31 17:45:59 +02:00
committed by GitHub
48 changed files with 574 additions and 111 deletions

View File

@@ -27,11 +27,12 @@
}); });
}, },
synchronizeInputs: function() { synchronizeInputs: function() {
var banners, inputs, processes, progress_bar; var banners, geozones, inputs, processes, progress_bar;
progress_bar = "[name='progress_bar[percentage]']"; progress_bar = "[name='progress_bar[percentage]']";
processes = "[name='legislation_process[background_color]'], [name='legislation_process[font_color]']"; processes = "[name='legislation_process[background_color]'], [name='legislation_process[font_color]']";
banners = "[name='banner[background_color]'], [name='banner[font_color]']"; banners = "[name='banner[background_color]'], [name='banner[font_color]']";
inputs = $(progress_bar + ", " + processes + ", " + banners); geozones = "[name='geozone[color]']";
inputs = $(progress_bar + ", " + processes + ", " + banners + ", " + geozones);
inputs.on({ inputs.on({
input: function() { input: function() {
$("[name='" + this.name + "']").val($(this).val()); $("[name='" + this.name + "']").val($(this).val());

View File

@@ -78,6 +78,7 @@
} }
App.Map.addInvestmentsMarkers(investmentsMarkers, createMarker); App.Map.addInvestmentsMarkers(investmentsMarkers, createMarker);
App.Map.addGeozones(map);
}, },
leafletMap: function(element) { leafletMap: function(element) {
var centerData, mapCenterLatLng; var centerData, mapCenterLatLng;
@@ -194,6 +195,28 @@
map.attributionControl.setPrefix(App.Map.attributionPrefix()); map.attributionControl.setPrefix(App.Map.attributionPrefix());
L.tileLayer(mapTilesProvider, { attribution: mapAttribution }).addTo(map); L.tileLayer(mapTilesProvider, { attribution: mapAttribution }).addTo(map);
}, },
addGeozones: function(map) {
var geozones = $(map._container).data("geozones");
if (geozones) {
geozones.forEach(function(geozone) {
App.Map.addGeozone(geozone, map);
});
}
},
addGeozone: function(geozone, map) {
var polygon = L.polygon(geozone.outline_points, {
color: geozone.color,
fillOpacity: 0.3,
className: "map-polygon"
});
if (geozone.headings !== undefined) {
polygon.bindPopup(geozone.headings.join("<br>"));
}
polygon.addTo(map);
},
openMarkerPopup: function(e) { openMarkerPopup: function(e) {
var marker = e.target; var marker = e.target;
$.ajax("/investments/" + marker.options.id + "/json_data", { $.ajax("/investments/" + marker.options.id + "/json_data", {

View File

@@ -12,12 +12,19 @@
<% end %> <% end %>
<div class="small-12 medium-6"> <div class="small-12 medium-6">
<% if @budget.show_money? %> <% if budget.show_money? %>
<%= f.text_field :price, maxlength: 8 %> <%= f.text_field :price, maxlength: 8 %>
<% else %> <% else %>
<%= f.hidden_field :price, value: 0 %> <%= f.hidden_field :price, value: 0 %>
<% end %> <% end %>
<% if feature?(:map) %>
<%= f.select :geozone_id,
geozone_options,
include_blank: t("geozones.none"),
hint: t("admin.budget_headings.form.geozone_info") %>
<% end %>
<% if heading.budget.approval_voting? %> <% if heading.budget.approval_voting? %>
<%= f.number_field :max_ballot_lines, <%= f.number_field :max_ballot_lines,
hint: t("admin.budget_headings.form.max_ballot_lines_info") %> hint: t("admin.budget_headings.form.max_ballot_lines_info") %>
@@ -41,7 +48,7 @@
</div> </div>
<div class="clear"> <div class="clear">
<% if respond_to?(:single_heading?) && single_heading? %> <% if single_heading? %>
<%= f.submit t("admin.budgets_wizard.headings.continue"), class: "button success" %> <%= f.submit t("admin.budgets_wizard.headings.continue"), class: "button success" %>
<% else %> <% else %>
<%= f.submit t("admin.budget_headings.form.#{action}"), class: "button hollow" %> <%= f.submit t("admin.budget_headings.form.#{action}"), class: "button hollow" %>

View File

@@ -0,0 +1,25 @@
class Admin::BudgetHeadings::FormComponent < ApplicationComponent
include TranslatableFormHelper
include GlobalizeHelper
attr_reader :heading, :path, :action
def initialize(heading, path:, action:)
@heading = heading
@path = path
@action = action
end
private
def budget
heading.budget
end
def single_heading?
helpers.respond_to?(:single_heading?) && helpers.single_heading?
end
def geozone_options
Geozone.all.map { |geozone| [geozone.name, geozone.id] }
end
end

View File

@@ -5,11 +5,12 @@
<tr> <tr>
<th><%= Budget::Heading.human_attribute_name(:name) %></th> <th><%= Budget::Heading.human_attribute_name(:name) %></th>
<% if budget.show_money? %> <% if budget.show_money? %>
<th class="text-center"><%= Budget::Heading.human_attribute_name(:price) %></th> <th><%= Budget::Heading.human_attribute_name(:price) %></th>
<% end %> <% end %>
<% if budget.approval_voting? %> <% if budget.approval_voting? %>
<th><%= Budget::Heading.human_attribute_name(:max_ballot_lines) %></th> <th><%= Budget::Heading.human_attribute_name(:max_ballot_lines) %></th>
<% end %> <% end %>
<th><%= Budget::Heading.human_attribute_name(:geozone_id) %></th>
<th><%= t("admin.actions.actions") %></th> <th><%= t("admin.actions.actions") %></th>
</tr> </tr>
</thead> </thead>
@@ -23,6 +24,9 @@
<% if budget.approval_voting? %> <% if budget.approval_voting? %>
<td><%= heading.max_ballot_lines %></td> <td><%= heading.max_ballot_lines %></td>
<% end %> <% end %>
<td>
<%= geozone_for(heading) %>
</td>
<td> <td>
<%= render Admin::TableActionsComponent.new(heading) %> <%= render Admin::TableActionsComponent.new(heading) %>
</td> </td>

View File

@@ -14,4 +14,12 @@ class Admin::BudgetHeadings::HeadingsComponent < ApplicationComponent
def budget def budget
@budget ||= group.budget @budget ||= group.budget
end end
def geozone_for(heading)
if heading.geozone
link_to heading.geozone.name, edit_admin_geozone_path(heading.geozone)
else
t("geozones.none")
end
end
end end

View File

@@ -1,3 +1,3 @@
<%= render Admin::BudgetsWizard::CreationStepComponent.new(heading, next_step_path) do %> <%= render Admin::BudgetsWizard::CreationStepComponent.new(heading, next_step_path) do %>
<%= render "/admin/budget_headings/form", heading: heading, path: form_path, action: "create" %> <%= render Admin::BudgetHeadings::FormComponent.new(heading, path: form_path, action: "create") %>
<% end %> <% end %>

View File

@@ -4,4 +4,4 @@
<%= render Admin::BudgetsWizard::CreationTimelineComponent.new("headings") %> <%= render Admin::BudgetsWizard::CreationTimelineComponent.new("headings") %>
<%= render "/admin/budget_headings/form", heading: heading, path: form_path, action: "submit" %> <%= render Admin::BudgetHeadings::FormComponent.new(heading, path: form_path, action: "submit") %>

View File

@@ -0,0 +1,46 @@
<%= form_for [:admin, geozone] do |f| %>
<%= render "shared/errors", resource: geozone %>
<div class="small-12 large-8 column">
<%= f.text_field :name %>
</div>
<div class="clear">
<div class="small-12 medium-6 large-4 column">
<%= f.text_field :census_code, hint: t("admin.geozones.geozone.code_help") %>
</div>
<div class="small-12 medium-6 large-4 column end">
<%= f.text_field :external_code, hint: t("admin.geozones.geozone.code_help") %>
</div>
</div>
<div class="small-12 large-8 column">
<%= f.text_field :html_map_coordinates, hint: t("admin.geozones.geozone.coordinates_help") %>
</div>
<div class="column">
<%= f.text_area :geojson, rows: "10", hint: t("admin.geozones.geozone.geojson_help") %>
</div>
<div class="small-12 large-3 column">
<%= f.label :color, nil, for: "color_input", id: "color_input_label" %>
<p class="help-text">
<%= t("admin.geozones.geozone.color_help", format_help: t("admin.shared.color_help")) %>
</p>
<div class="row collapse">
<div class="small-12 medium-6 column">
<%= f.text_field :color, label: false, type: :color %>
</div>
<div class="small-12 medium-6 column">
<%= f.text_field :color, label: false, id: "color_input" %>
</div>
</div>
</div>
<div class="small-12 column">
<%= f.submit(value: t("admin.geozones.edit.form.submit_button"),
class: "button success") %>
</div>
<% end %>

View File

@@ -0,0 +1,7 @@
class Admin::Geozones::FormComponent < ApplicationComponent
attr_reader :geozone
def initialize(geozone)
@geozone = geozone
end
end

View File

@@ -0,0 +1,31 @@
<%= header do %>
<%= link_to t("admin.geozones.index.create"), new_admin_geozone_path %>
<% end %>
<table>
<thead>
<tr>
<th><%= t("admin.geozones.geozone.name") %></th>
<th><%= t("admin.geozones.geozone.external_code") %></th>
<th><%= t("admin.geozones.geozone.census_code") %></th>
<th><%= t("admin.geozones.geozone.coordinates") %></th>
<th><%= t("admin.geozones.geozone.geojson") %></th>
<th><%= t("admin.actions.actions") %></th>
</tr>
</thead>
<tbody>
<% geozones.each do |geozone| %>
<tr id="<%= dom_id(geozone) %>">
<td><%= geozone.name %></td>
<td><%= geozone.external_code %></td>
<td><%= geozone.census_code %></td>
<td><%= yes_no_text(geozone.html_map_coordinates.present?) %></td>
<td><%= yes_no_text(geozone.geojson.present?) %></td>
<td>
<%= render Admin::TableActionsComponent.new(geozone) %>
</td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,22 @@
class Admin::Geozones::IndexComponent < ApplicationComponent
include Header
attr_reader :geozones
def initialize(geozones)
@geozones = geozones
end
private
def title
t("admin.geozones.index.title")
end
def yes_no_text(condition)
if condition
t("shared.yes")
else
t("shared.no")
end
end
end

View File

@@ -0,0 +1,3 @@
<div class="budget-investments-map">
<%= render_map(map_location, investments_coordinates: coordinates, geozones_data: geozones_data) %>
</div>

View File

@@ -0,0 +1,34 @@
class Budgets::Investments::MapComponent < ApplicationComponent
attr_reader :heading, :investments
delegate :render_map, to: :helpers
def initialize(investments, heading:)
@investments = investments
@heading = heading
end
def render?
map_location&.available?
end
private
def map_location
MapLocation.from_heading(heading) if heading.present?
end
def coordinates
MapLocation.where(investment: investments).map(&:json_data)
end
def geozones_data
return unless heading.geozone.present?
[
{
outline_points: heading.geozone.outline_points,
color: heading.geozone.color
}
]
end
end

View File

@@ -1,4 +1,4 @@
<div class="map inline"> <div class="budgets-map">
<h2><%= t("budgets.index.map") %></h2> <h2><%= t("budgets.index.map") %></h2>
<%= render_map(nil, investments_coordinates: coordinates) %> <%= render_map(nil, investments_coordinates: coordinates, geozones_data: geozones_data) %>
</div> </div>

View File

@@ -13,8 +13,6 @@ class Budgets::MapComponent < ApplicationComponent
private private
def coordinates def coordinates
return unless budget.present?
if budget.publishing_prices_or_later? && budget.investments.selected.any? if budget.publishing_prices_or_later? && budget.investments.selected.any?
investments = budget.investments.selected investments = budget.investments.selected
else else
@@ -23,4 +21,16 @@ class Budgets::MapComponent < ApplicationComponent
MapLocation.where(investment_id: investments).map(&:json_data) MapLocation.where(investment_id: investments).map(&:json_data)
end end
def geozones_data
budget.geozones.map do |geozone|
{
outline_points: geozone.outline_points,
color: geozone.color,
headings: budget.headings.where(geozone: geozone).map do |heading|
link_to heading.name, budget_investments_path(budget, heading_id: heading.id)
end
}
end
end
end end

View File

@@ -1,10 +1,11 @@
class Shared::MapLocationComponent < ApplicationComponent class Shared::MapLocationComponent < ApplicationComponent
attr_reader :investments_coordinates, :form attr_reader :investments_coordinates, :form, :geozones_data
def initialize(map_location, investments_coordinates: nil, form: nil) def initialize(map_location, investments_coordinates: nil, form: nil, geozones_data: nil)
@map_location = map_location @map_location = map_location
@investments_coordinates = investments_coordinates @investments_coordinates = investments_coordinates
@form = form @form = form
@geozones_data = geozones_data
end end
def map_location def map_location
@@ -56,7 +57,8 @@ class Shared::MapLocationComponent < ApplicationComponent
marker_remove_selector: "##{remove_marker_id}", marker_remove_selector: "##{remove_marker_id}",
marker_investments_coordinates: investments_coordinates, marker_investments_coordinates: investments_coordinates,
marker_latitude: map_location.latitude.presence, marker_latitude: map_location.latitude.presence,
marker_longitude: map_location.longitude.presence marker_longitude: map_location.longitude.presence,
geozones: geozones_data
}.merge(input_selectors) }.merge(input_selectors)
end end

View File

@@ -17,7 +17,7 @@ class Admin::GeozonesController < Admin::BaseController
@geozone = Geozone.new(geozone_params) @geozone = Geozone.new(geozone_params)
if @geozone.save if @geozone.save
redirect_to admin_geozones_path redirect_to admin_geozones_path, notice: t("admin.geozones.create.notice")
else else
render :new render :new
end end
@@ -25,7 +25,7 @@ class Admin::GeozonesController < Admin::BaseController
def update def update
if @geozone.update(geozone_params) if @geozone.update(geozone_params)
redirect_to admin_geozones_path redirect_to admin_geozones_path, notice: t("admin.geozones.update.notice")
else else
render :edit render :edit
end end
@@ -47,6 +47,6 @@ class Admin::GeozonesController < Admin::BaseController
end end
def allowed_params def allowed_params
[:name, :external_code, :census_code, :html_map_coordinates] [:name, :external_code, :census_code, :html_map_coordinates, :geojson, :color]
end end
end end

View File

@@ -20,7 +20,6 @@ module Budgets
before_action :load_ballot, only: [:index, :show] before_action :load_ballot, only: [:index, :show]
before_action :load_heading, only: [:index, :show] before_action :load_heading, only: [:index, :show]
before_action :load_map, only: [:index]
before_action :set_random_seed, only: :index before_action :set_random_seed, only: :index
before_action :load_categories, only: :index before_action :load_categories, only: :index
before_action :set_default_investment_filter, only: :index before_action :set_default_investment_filter, only: :index
@@ -41,10 +40,9 @@ module Budgets
def index def index
@investments = investments.page(params[:page]).per(PER_PAGE).for_render @investments = investments.page(params[:page]).per(PER_PAGE).for_render
@investment_ids = @investments.ids @investment_ids = @investments.ids
@investments_map_coordinates = MapLocation.where(investment: investments).map(&:json_data)
@investments_in_map = investments
@tag_cloud = tag_cloud @tag_cloud = tag_cloud
@remote_translations = detect_remote_translations(@investments) @remote_translations = detect_remote_translations(@investments)
end end
@@ -179,9 +177,5 @@ module Budgets
params[:filter] ||= "selected" params[:filter] ||= "selected"
end end
end end
def load_map
@map_location = MapLocation.from_heading(@heading) if @heading.present?
end
end end
end end

View File

@@ -59,7 +59,7 @@ module Admin::BudgetHeadingsActions
end end
def allowed_params def allowed_params
valid_attributes = [:price, :population, :allow_custom_content, :latitude, :longitude, :max_ballot_lines] valid_attributes = [:price, :population, :allow_custom_content, :latitude, :longitude, :max_ballot_lines, :geozone_id]
[*valid_attributes, translation_params(Budget::Heading)] [*valid_attributes, translation_params(Budget::Heading)]
end end

View File

@@ -34,6 +34,7 @@ class Budget < ApplicationRecord
has_many :ballots, dependent: :destroy has_many :ballots, dependent: :destroy
has_many :groups, dependent: :destroy has_many :groups, dependent: :destroy
has_many :headings, through: :groups has_many :headings, through: :groups
has_many :geozones, through: :headings
has_many :lines, through: :ballots, class_name: "Budget::Ballot::Line" has_many :lines, through: :ballots, class_name: "Budget::Ballot::Line"
has_many :phases, class_name: "Budget::Phase" has_many :phases, class_name: "Budget::Phase"
has_many :budget_administrators, dependent: :destroy has_many :budget_administrators, dependent: :destroy

View File

@@ -22,6 +22,7 @@ class Budget
end end
belongs_to :group belongs_to :group
belongs_to :geozone
has_many :investments has_many :investments
has_many :content_blocks has_many :content_blocks

View File

@@ -0,0 +1,23 @@
class GeojsonFormatValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if value.present?
geojson = parse_json(value)
unless geojson?(geojson)
record.errors.add(attribute, :invalid)
end
end
end
private
def parse_json(geojson_data)
JSON.parse(geojson_data) rescue nil
end
def geojson?(geojson)
return false unless geojson.is_a?(Hash)
geojson.dig("geometry", "coordinates").is_a?(Array)
end
end

View File

@@ -4,7 +4,9 @@ class Geozone < ApplicationRecord
has_many :proposals has_many :proposals
has_many :debates has_many :debates
has_many :users has_many :users
has_many :headings, class_name: "Budget::Heading", dependent: :nullify
validates :name, presence: true validates :name, presence: true
validates :geojson, geojson_format: true
scope :public_for_api, -> { all } scope :public_for_api, -> { all }
@@ -17,4 +19,28 @@ class Geozone < ApplicationRecord
association.klass.where(geozone: self).empty? association.klass.where(geozone: self).empty?
end end
end end
def outline_points
normalized_coordinates.map { |longlat| [longlat.last, longlat.first] }
end
private
def normalized_coordinates
if geojson.present?
if geojson.match(/"coordinates"\s*:\s*\[{4}/)
coordinates.reduce([], :concat).reduce([], :concat)
elsif geojson.match(/"coordinates"\s*:\s*\[{3}/)
coordinates.reduce([], :concat)
else
coordinates
end
else
[]
end
end
def coordinates
JSON.parse(geojson)["geometry"]["coordinates"]
end
end end

View File

@@ -1,3 +1,7 @@
<%= render "header", action: "edit" %> <%= render "header", action: "edit" %>
<%= render "form", heading: @heading, path: admin_budget_group_heading_path(@budget, @group, @heading), action: "submit" %> <%= render Admin::BudgetHeadings::FormComponent.new(
@heading,
path: admin_budget_group_heading_path(@budget, @group, @heading),
action: "submit"
) %>

View File

@@ -1,3 +1,7 @@
<%= render "header", action: "create" %> <%= render "header", action: "create" %>
<%= render "form", heading: @heading, path: admin_budget_group_headings_path(@budget, @group), action: "create" %> <%= render Admin::BudgetHeadings::FormComponent.new(
@heading,
path: admin_budget_group_headings_path(@budget, @group),
action: "create"
) %>

View File

@@ -1,27 +0,0 @@
<%= form_for [:admin, @geozone] do |f| %>
<%= render "shared/errors", resource: @geozone %>
<div class="small-12 large-8 column">
<%= f.text_field :name %>
</div>
<div class="clear">
<div class="small-12 medium-6 large-4 column">
<%= f.text_field :census_code, hint: t("admin.geozones.geozone.code_help") %>
</div>
<div class="small-12 medium-6 large-4 column end">
<%= f.text_field :external_code, hint: t("admin.geozones.geozone.code_help") %>
</div>
</div>
<div class="small-12 large-8 column">
<%= f.text_field :html_map_coordinates, hint: t("admin.geozones.geozone.coordinates_help") %>
</div>
<div class="small-12 column">
<%= f.submit(value: t("admin.geozones.edit.form.submit_button"),
class: "button success") %>
</div>
<% end %>

View File

@@ -4,4 +4,4 @@
<h2><%= t("admin.geozones.edit.editing") %></h2> <h2><%= t("admin.geozones.edit.editing") %></h2>
</div> </div>
<%= render "form" %> <%= render Admin::Geozones::FormComponent.new(@geozone) %>

View File

@@ -1,30 +1 @@
<%= link_to t("admin.geozones.index.create"), <%= render Admin::Geozones::IndexComponent.new(@geozones) %>
new_admin_geozone_path, class: "button float-right" %>
<h2 class="inline-block"><%= t("admin.geozones.index.title") %></h2>
<table>
<thead>
<tr>
<th><%= t("admin.geozones.geozone.name") %></th>
<th><%= t("admin.geozones.geozone.external_code") %></th>
<th><%= t("admin.geozones.geozone.census_code") %></th>
<th><%= t("admin.geozones.geozone.coordinates") %></th>
<th><%= t("admin.actions.actions") %></th>
</tr>
</thead>
<tbody>
<% @geozones.each do |geozone| %>
<tr id="<%= dom_id(geozone) %>">
<td><%= geozone.name %></td>
<td><%= geozone.external_code %></td>
<td><%= geozone.census_code %></td>
<td class="break"><%= geozone.html_map_coordinates %></td>
<td>
<%= render Admin::TableActionsComponent.new(geozone) %>
</td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -4,4 +4,4 @@
<h2><%= t("admin.geozones.new.creating") %></h2> <h2><%= t("admin.geozones.new.creating") %></h2>
</div> </div>
<%= render "form" %> <%= render Admin::Geozones::FormComponent.new(@geozone) %>

View File

@@ -1,3 +0,0 @@
<div class="map">
<%= render_map(@map_location, investments_coordinates: @investments_map_coordinates) %>
</div>

View File

@@ -22,10 +22,7 @@
</div> </div>
<%= render Budgets::Investments::ContentBlocksComponent.new(@heading) %> <%= render Budgets::Investments::ContentBlocksComponent.new(@heading) %>
<%= render Budgets::Investments::MapComponent.new(@investments_in_map, heading: @heading) %>
<% if @map_location&.available? %>
<%= render "budgets/investments/map" %>
<% end %>
<%= render "shared/tag_cloud", taggable: "Budget::Investment" %> <%= render "shared/tag_cloud", taggable: "Budget::Investment" %>
<%= render "budgets/investments/categories" %> <%= render "budgets/investments/categories" %>
<%= render Budgets::Investments::FiltersComponent.new %> <%= render Budgets::Investments::FiltersComponent.new %>

View File

@@ -201,7 +201,9 @@ en:
geozone: geozone:
name: Name name: Name
external_code: "External code (optional)" external_code: "External code (optional)"
color: "Color (optional)"
census_code: "Census code (optional)" census_code: "Census code (optional)"
geojson: "GeoJSON data (optional)"
html_map_coordinates: "HTML <map> Coordinates (optional)" html_map_coordinates: "HTML <map> Coordinates (optional)"
milestone: milestone:
status_id: "Current status (optional)" status_id: "Current status (optional)"
@@ -545,6 +547,10 @@ en:
attributes: attributes:
max_per_day: max_per_day:
invalid: "You have reached the maximum number of private messages per day" invalid: "You have reached the maximum number of private messages per day"
geozone:
attributes:
geojson:
invalid: "The GeoJSON provided does not follow the correct format. It must follow the \"Polygon\" or \"MultiPolygon\" type format."
image: image:
attributes: attributes:
attachment: attachment:

View File

@@ -198,6 +198,7 @@ en:
success_notice: "Heading deleted successfully" success_notice: "Heading deleted successfully"
unable_notice: "You cannot delete a Heading that has associated investments" unable_notice: "You cannot delete a Heading that has associated investments"
form: form:
geozone_info: "When a heading is associated with a geozone, the geozone will appear on this budget's map if the geozone includes GeoJSON data."
max_ballot_lines_info: 'Maximum number of projects a user can vote on this heading during the "Voting projects" phase. Only for budgets using approval voting.' max_ballot_lines_info: 'Maximum number of projects a user can vote on this heading during the "Voting projects" phase. Only for budgets using approval voting.'
population_info: "Budget Heading population field is used for Statistic purposes at the end of the Budget to show for each Heading that represents an area with population what percentage voted. The field is optional so you can leave it empty if it doesn't apply." population_info: "Budget Heading population field is used for Statistic purposes at the end of the Budget to show for each Heading that represents an area with population what percentage voted. The field is optional so you can leave it empty if it doesn't apply."
coordinates_info: "If latitude and longitude are provided, the investments page for this heading will include a map. This map will be centered using those coordinates." coordinates_info: "If latitude and longitude are provided, the investments page for this heading will include a map. This map will be centered using those coordinates."
@@ -1391,8 +1392,13 @@ en:
external_code: External code external_code: External code
census_code: Census code census_code: Census code
code_help: Response code for this geozone on the census API code_help: Response code for this geozone on the census API
coordinates: Coordinates color_help: "Color of the zone in a budget's map. %{format_help}"
coordinates: Coordinates available
coordinates_help: Coordinates that will generate a clickable area on an HTML image map coordinates_help: Coordinates that will generate a clickable area on an HTML image map
geojson: GeoJSON available
geojson_help: "Must follow the \"Polygon\" or \"MultiPolygon\" type format; on a budget's map, a polygon based on this data will appear when a heading is associated to this geozone"
create:
notice: "Geozone created successfully"
edit: edit:
form: form:
submit_button: Save changes submit_button: Save changes
@@ -1404,6 +1410,8 @@ en:
delete: delete:
success: Geozone successfully deleted success: Geozone successfully deleted
error: This geozone can't be deleted since there are elements attached to it error: This geozone can't be deleted since there are elements attached to it
update:
notice: "Geozone updated successfully"
signature_sheets: signature_sheets:
author: Author author: Author
created_at: Creation date created_at: Creation date

View File

@@ -200,8 +200,10 @@ es:
description: "Descripción" description: "Descripción"
geozone: geozone:
name: Nombre name: Nombre
color: "Color (opcional)"
external_code: "Código externo (opcional)" external_code: "Código externo (opcional)"
census_code: "Código del censo (opcional)" census_code: "Código del censo (opcional)"
geojson: "Datos GeoJSON (opcional)"
html_map_coordinates: "Coordenadas HTML <map> (opcional)" html_map_coordinates: "Coordenadas HTML <map> (opcional)"
milestone: milestone:
status_id: "Estado actual (opcional)" status_id: "Estado actual (opcional)"
@@ -545,6 +547,10 @@ es:
attributes: attributes:
max_per_day: max_per_day:
invalid: "Has llegado al número máximo de mensajes privados por día" invalid: "Has llegado al número máximo de mensajes privados por día"
geozone:
attributes:
geojson:
invalid: "Los datos GeoJSON proporcionados no tienen el formato correcto. Deben tener un tipo del formato \"Polygon\" o \"MultiPolygon\"."
image: image:
attributes: attributes:
attachment: attachment:

View File

@@ -198,6 +198,7 @@ es:
success_notice: "Partida presupuestaria eliminada correctamente" success_notice: "Partida presupuestaria eliminada correctamente"
unable_notice: "No se puede eliminar una partida presupuestaria con proyectos asociados" unable_notice: "No se puede eliminar una partida presupuestaria con proyectos asociados"
form: form:
geozone_info: "Cuando se asocia una partida a una zona, la zona aparecerá en el mapa de este presupuesto si la zona incluye datos de GeoJSON."
max_ballot_lines_info: 'Máximo número de proyectos que un usuario puede votar en esta partida durante la fase "Votación final". Solamente se aplica a presupuestos con votación por aprobación.' max_ballot_lines_info: 'Máximo número de proyectos que un usuario puede votar en esta partida durante la fase "Votación final". Solamente se aplica a presupuestos con votación por aprobación.'
population_info: "El campo población de las partidas presupuestarias se usa con fines estadísticos únicamente, con el objetivo de mostrar el porcentaje de votos habidos en cada partida que represente un área con población. Es un campo opcional, así que puedes dejarlo en blanco si no aplica." population_info: "El campo población de las partidas presupuestarias se usa con fines estadísticos únicamente, con el objetivo de mostrar el porcentaje de votos habidos en cada partida que represente un área con población. Es un campo opcional, así que puedes dejarlo en blanco si no aplica."
coordinates_info: "Si se añaden los campos latitud y longitud, en la página de proyectos de esta partida aparecerá un mapa, que estará centrado en esas coordenadas." coordinates_info: "Si se añaden los campos latitud y longitud, en la página de proyectos de esta partida aparecerá un mapa, que estará centrado en esas coordenadas."
@@ -1391,8 +1392,13 @@ es:
external_code: Código externo external_code: Código externo
census_code: Código del censo census_code: Código del censo
code_help: Código de respuesta para esta zona en la API del censo code_help: Código de respuesta para esta zona en la API del censo
coordinates: Coordenadas color_help: "Color con el que aparecerá esta zona en el mapa de un presupuesto. %{format_help}"
coordinates: Coordenadas disponibles
coordinates_help: Coordenadas que generarán una zona clicable en un mapa de imagen HTML coordinates_help: Coordenadas que generarán una zona clicable en un mapa de imagen HTML
geojson: GeoJSON disponible
geojson_help: "Deben tener un tipo del formato \"Polygon\" o \"MultiPolygon\"; en el mapa de un presupuesto aparecerá un polígono basado en estos datos si una partida se asocia a esta geozona"
create:
notice: "Zona creada correctamente"
edit: edit:
form: form:
submit_button: Guardar cambios submit_button: Guardar cambios
@@ -1404,6 +1410,8 @@ es:
delete: delete:
success: Zona borrada correctamente success: Zona borrada correctamente
error: No se puede borrar la zona porque ya tiene elementos asociados error: No se puede borrar la zona porque ya tiene elementos asociados
update:
notice: "Zona actualizada correctamente"
signature_sheets: signature_sheets:
author: Autor author: Autor
created_at: Fecha de creación created_at: Fecha de creación

View File

@@ -0,0 +1,8 @@
class AddPolygonsToGeozones < ActiveRecord::Migration[5.0]
def change
change_table :geozones do |t|
t.text :geojson
t.string :color
end
end
end

View File

@@ -0,0 +1,5 @@
class AddGeozoneToHeadings < ActiveRecord::Migration[5.0]
def change
add_reference :budget_headings, :geozone, index: true, foreign_key: true
end
end

View File

@@ -238,9 +238,11 @@ ActiveRecord::Schema.define(version: 2023_05_23_090028) do
t.boolean "allow_custom_content", default: false t.boolean "allow_custom_content", default: false
t.text "latitude" t.text "latitude"
t.text "longitude" t.text "longitude"
t.integer "geozone_id"
t.integer "max_ballot_lines", default: 1 t.integer "max_ballot_lines", default: 1
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
t.index ["geozone_id"], name: "index_budget_headings_on_geozone_id"
t.index ["group_id"], name: "index_budget_headings_on_group_id" t.index ["group_id"], name: "index_budget_headings_on_group_id"
end end
@@ -635,6 +637,8 @@ ActiveRecord::Schema.define(version: 2023_05_23_090028) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "census_code" t.string "census_code"
t.text "geojson"
t.string "color"
end end
create_table "geozones_polls", id: :serial, force: :cascade do |t| create_table "geozones_polls", id: :serial, force: :cascade do |t|
@@ -1775,6 +1779,7 @@ ActiveRecord::Schema.define(version: 2023_05_23_090028) do
add_foreign_key "administrators", "users" add_foreign_key "administrators", "users"
add_foreign_key "budget_administrators", "administrators" add_foreign_key "budget_administrators", "administrators"
add_foreign_key "budget_administrators", "budgets" add_foreign_key "budget_administrators", "budgets"
add_foreign_key "budget_headings", "geozones"
add_foreign_key "budget_investments", "communities" add_foreign_key "budget_investments", "communities"
add_foreign_key "budget_valuators", "budgets" add_foreign_key "budget_valuators", "budgets"
add_foreign_key "budget_valuators", "valuators" add_foreign_key "budget_valuators", "valuators"

View File

@@ -0,0 +1,32 @@
require "rails_helper"
describe Admin::BudgetHeadings::FormComponent do
describe "geozone field" do
let(:heading) { create(:budget_heading) }
let(:component) { Admin::BudgetHeadings::FormComponent.new(heading, path: "/", action: nil) }
before { Setting["feature.map"] = true }
it "is shown when the map feature is enabled" do
render_inline component
expect(page).to have_select "Scope of operation"
end
it "is not shown when the map feature is disabled" do
Setting["feature.map"] = false
render_inline component
expect(page).not_to have_select "Scope of operation"
end
it "includes all existing geozones plus an option for all city" do
create(:geozone, name: "Under the sea")
create(:geozone, name: "Above the skies")
render_inline component
expect(page).to have_select "Scope of operation", options: ["All city", "Under the sea", "Above the skies"]
end
end
end

View File

@@ -1,6 +1,6 @@
require "rails_helper" require "rails_helper"
describe Admin::BudgetHeadings::HeadingsComponent do describe Admin::BudgetHeadings::HeadingsComponent, controller: Admin::BaseController do
it "includes group name in the message when there are no headings" do it "includes group name in the message when there are no headings" do
group = create(:budget_group, name: "Whole planet") group = create(:budget_group, name: "Whole planet")
@@ -9,4 +9,22 @@ describe Admin::BudgetHeadings::HeadingsComponent do
expect(page.text.strip).to eq "There are no headings in the Whole planet group." expect(page.text.strip).to eq "There are no headings in the Whole planet group."
expect(page).to have_css "strong", exact_text: "Whole planet" expect(page).to have_css "strong", exact_text: "Whole planet"
end end
describe "#geozone_for" do
it "shows the geozone associated to the heading" do
heading = create(:budget_heading, name: "Local", geozone: create(:geozone, name: "Here"))
render_inline Admin::BudgetHeadings::HeadingsComponent.new(heading.group.headings)
expect(page.find("tr", text: "Local")).to have_content "Here"
end
it "shows a generic location for headings with no associated geozone" do
heading = create(:budget_heading, name: "Universal", geozone: nil)
render_inline Admin::BudgetHeadings::HeadingsComponent.new(heading.group.headings)
expect(page.find("tr", text: "Universal")).to have_content "All city"
end
end
end end

View File

@@ -0,0 +1,23 @@
require "rails_helper"
describe Admin::Geozones::IndexComponent, controller: Admin::BaseController do
describe "Coordinates description" do
it "includes whether coordinates are defined or not" do
geozones = [
create(:geozone, :with_geojson, name: "GeoJSON", external_code: "1", census_code: "2"),
create(:geozone, :with_html_coordinates, name: "HTML", external_code: "3", census_code: "4"),
create(:geozone, :with_geojson, :with_html_coordinates, name: "With both", external_code: "6", census_code: "7"),
create(:geozone, name: "With none", external_code: "8", census_code: "9")
]
render_inline Admin::Geozones::IndexComponent.new(geozones)
expect(page).to have_table with_rows: [
["GeoJSON", "1", "2", "No", "Yes", "Edit Delete"],
["HTML", "3", "4", "Yes", "No", "Edit Delete"],
["With both", "6", "7", "Yes", "Yes", "Edit Delete"],
["With none", "8", "9", "No", "No", "Edit Delete"]
]
end
end
end

View File

@@ -1,20 +1,21 @@
require "rails_helper" require "rails_helper"
describe Budgets::MapComponent do describe Budgets::MapComponent do
let(:budget) { build(:budget) } before { Setting["feature.map"] = true }
let(:budget) { create(:budget, :accepting) }
describe "#render?" do describe "#render?" do
let(:budget) { build(:budget) }
it "is rendered after the informing phase when the map feature is enabled" do it "is rendered after the informing phase when the map feature is enabled" do
Setting["feature.map"] = true
budget.phase = "accepting" budget.phase = "accepting"
render_inline Budgets::MapComponent.new(budget) render_inline Budgets::MapComponent.new(budget)
expect(page.first("div.map")).to have_content "located geographically" expect(page.find(".budgets-map")).to have_content "located geographically"
end end
it "is not rendered during the informing phase" do it "is not rendered during the informing phase" do
Setting["feature.map"] = true
budget.phase = "informing" budget.phase = "informing"
render_inline Budgets::MapComponent.new(budget) render_inline Budgets::MapComponent.new(budget)
@@ -31,4 +32,25 @@ describe Budgets::MapComponent do
expect(page).not_to be_rendered expect(page).not_to be_rendered
end end
end end
describe "#geozones_data" do
it "renders data for the geozones associated with the budget" do
create(:budget_heading, geozone: create(:geozone, color: "#0000ff"), budget: budget)
create(:budget_heading, geozone: create(:geozone, color: "#ff0000"), budget: create(:budget))
render_inline Budgets::MapComponent.new(budget)
expect(page).to have_css "[data-geozones*='#0000ff']"
expect(page).not_to have_css "[data-geozones*='#ff0000']"
end
it "renders empty geozone data when there are no geozones" do
create(:budget_heading, geozone: nil, budget: budget)
create(:budget_heading, geozone: create(:geozone, color: "#ff0000"), budget: create(:budget))
render_inline Budgets::MapComponent.new(budget)
expect(page).to have_css "[data-geozones='[]']"
end
end
end end

View File

@@ -8,10 +8,19 @@ FactoryBot.define do
sequence(:name) { |n| "District #{n}" } sequence(:name) { |n| "District #{n}" }
sequence(:external_code, &:to_s) sequence(:external_code, &:to_s)
sequence(:census_code, &:to_s) sequence(:census_code, &:to_s)
color { "#0081aa" }
trait :in_census do trait :in_census do
census_code { "01" } census_code { "01" }
end end
trait :with_html_coordinates do
html_map_coordinates { "30,139,45,153,77,148,107,165" }
end
trait :with_geojson do
geojson { '{ "geometry": { "type": "Polygon", "coordinates": [[-0.117,51.513],[-0.118,51.512],[-0.119,51.514]] } }' }
end
end end
factory :banner do factory :banner do

View File

@@ -12,6 +12,19 @@ describe Geozone do
expect(geozone).not_to be_valid expect(geozone).not_to be_valid
end end
it "is valid without geojson" do
geozone.geojson = nil
expect(geozone).to be_valid
end
it "is not valid with invalid geojson file format" do
geozone.geojson = '{"geo\":{"type":"Incorrect key","coordinates": [
[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]]}}'
expect(geozone).not_to be_valid
end
describe "#safe_to_destroy?" do describe "#safe_to_destroy?" do
let(:geozone) { create(:geozone) } let(:geozone) { create(:geozone) }
@@ -33,5 +46,34 @@ describe Geozone do
create(:debate, geozone: geozone) create(:debate, geozone: geozone)
expect(geozone).not_to be_safe_to_destroy expect(geozone).not_to be_safe_to_destroy
end end
it "is false when already linked to a heading" do
create(:budget_heading, geozone: geozone)
expect(geozone).not_to be_safe_to_destroy
end
end
describe "#outline_points" do
it "returns empty array when geojson is nil" do
expect(geozone.outline_points).to eq([])
end
it "returns coordinates array when geojson is not nil" do
geozone = build(:geozone, geojson: '{
"geometry": {
"type": "Polygon",
"coordinates": [
[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]
]
}
}')
expect(geozone.outline_points).to eq(
[[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224]])
end
end end
end end

View File

@@ -112,14 +112,11 @@ describe "Budgets creation wizard", :admin do
click_link "Finish" click_link "Finish"
within "section", text: "Heading groups" do within "section", text: "Heading groups" do
within "section", text: "All city" do
within_table "Headings in All city" do within_table "Headings in All city" do
expect(page).to have_css "tbody tr", count: 1 expect(page).to have_css "tbody tr", count: 1
expect(page).to have_content "All city" expect(page).to have_content "All city"
end end
end
within "section", text: "Districts" do
within_table "Headings in Districts" do within_table "Headings in Districts" do
expect(page).to have_css "tbody tr", count: 2 expect(page).to have_css "tbody tr", count: 2
expect(page).to have_content "North" expect(page).to have_content "North"
@@ -127,5 +124,4 @@ describe "Budgets creation wizard", :admin do
end end
end end
end end
end
end end

View File

@@ -27,6 +27,7 @@ describe "Admin geozones", :admin do
click_button "Save changes" click_button "Save changes"
expect(page).to have_content "Geozone created successfully"
expect(page).to have_content "Fancy District" expect(page).to have_content "Fancy District"
visit admin_geozones_path visit admin_geozones_path
@@ -46,6 +47,8 @@ describe "Admin geozones", :admin do
click_button "Save changes" click_button "Save changes"
expect(page).to have_content "Geozone updated successfully"
within("#geozone_#{geozone.id}") do within("#geozone_#{geozone.id}") do
expect(page).to have_content "New geozone name" expect(page).to have_content "New geozone name"
expect(page).to have_content "333" expect(page).to have_content "333"
@@ -64,6 +67,8 @@ describe "Admin geozones", :admin do
click_button "Save changes" click_button "Save changes"
expect(page).to have_content "Geozone updated successfully"
within("#geozone_#{geozone.id}") do within("#geozone_#{geozone.id}") do
expect(page).to have_content "New geozone name" expect(page).to have_content "New geozone name"
end end
@@ -102,4 +107,39 @@ describe "Admin geozones", :admin do
expect(page).to have_content "Delete me!" expect(page).to have_content "Delete me!"
end end
end end
scenario "Show polygons when a heading is associated with a geozone" do
Setting["feature.map"] = true
geojson = '{ "geometry": { "type": "Polygon", "coordinates": [[-0.1,51.5],[-0.2,51.4],[-0.3,51.6]] } }'
geozone = create(:geozone, name: "Polygon me!")
budget = create(:budget)
group = create(:budget_group, budget: budget)
heading = create(:budget_heading, name: "Area 51", group: group)
visit edit_admin_geozone_path(geozone)
fill_in "GeoJSON data (optional)", with: geojson
fill_in "Color (optional)", with: "#f5c211"
click_button "Save changes"
expect(page).to have_content "Geozone updated successfully"
visit edit_admin_budget_group_heading_path(budget, group, heading)
select "Polygon me!", from: "Scope of operation"
click_button "Save heading"
expect(page).to have_content "Heading updated successfully"
visit budget_path(budget)
expect(page).to have_css ".map-polygon[fill='#f5c211']"
within(".map-location") { expect(page).not_to have_link "Area 51" }
find(".map-polygon").click
within ".map-location" do
expect(page).to have_link "Area 51", href: budget_investments_path(budget, heading_id: heading.id)
end
end
end end

View File

@@ -1698,6 +1698,27 @@ describe "Budget Investments" do
end end
end end
scenario "Shows the polygon associated to the current heading" do
triangle = '{ "geometry": { "type": "Polygon", "coordinates": [[-0.1,51.5],[-0.2,51.4],[-0.3,51.6]] } }'
rectangle = '{ "geometry": { "type": "Polygon", "coordinates": [[-0.1,51.5],[-0.2,51.5],[-0.2,51.6],[-0.1,51.6]] } }'
park = create(:geozone, geojson: triangle, color: "#03ee03")
square = create(:geozone, geojson: rectangle, color: "#ff04ff")
group = create(:budget_group)
green_areas = create(:budget_heading, group: group, geozone: park, latitude: 51.5, longitude: -0.2)
create(:budget_heading, group: group, geozone: square, latitude: 51.5, longitude: -0.2)
visit budget_investments_path(group.budget, heading_id: green_areas)
expect(page).to have_css ".map-polygon[fill='#03ee03']"
expect(page).not_to have_css ".map-polygon[fill='#ff04ff']"
find(".map-polygon").click
expect(page).not_to have_css ".leaflet-popup"
end
scenario "Shows all investments and not only the ones on the current page" do scenario "Shows all investments and not only the ones on the current page" do
stub_const("#{Budgets::InvestmentsController}::PER_PAGE", 2) stub_const("#{Budgets::InvestmentsController}::PER_PAGE", 2)