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() {
var banners, inputs, processes, progress_bar;
var banners, geozones, inputs, processes, progress_bar;
progress_bar = "[name='progress_bar[percentage]']";
processes = "[name='legislation_process[background_color]'], [name='legislation_process[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({
input: function() {
$("[name='" + this.name + "']").val($(this).val());

View File

@@ -78,6 +78,7 @@
}
App.Map.addInvestmentsMarkers(investmentsMarkers, createMarker);
App.Map.addGeozones(map);
},
leafletMap: function(element) {
var centerData, mapCenterLatLng;
@@ -194,6 +195,28 @@
map.attributionControl.setPrefix(App.Map.attributionPrefix());
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) {
var marker = e.target;
$.ajax("/investments/" + marker.options.id + "/json_data", {

View File

@@ -12,12 +12,19 @@
<% end %>
<div class="small-12 medium-6">
<% if @budget.show_money? %>
<% if budget.show_money? %>
<%= f.text_field :price, maxlength: 8 %>
<% else %>
<%= f.hidden_field :price, value: 0 %>
<% 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? %>
<%= f.number_field :max_ballot_lines,
hint: t("admin.budget_headings.form.max_ballot_lines_info") %>
@@ -41,7 +48,7 @@
</div>
<div class="clear">
<% if respond_to?(:single_heading?) && single_heading? %>
<% if single_heading? %>
<%= f.submit t("admin.budgets_wizard.headings.continue"), class: "button success" %>
<% else %>
<%= 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>
<th><%= Budget::Heading.human_attribute_name(:name) %></th>
<% if budget.show_money? %>
<th class="text-center"><%= Budget::Heading.human_attribute_name(:price) %></th>
<th><%= Budget::Heading.human_attribute_name(:price) %></th>
<% end %>
<% if budget.approval_voting? %>
<th><%= Budget::Heading.human_attribute_name(:max_ballot_lines) %></th>
<% end %>
<th><%= Budget::Heading.human_attribute_name(:geozone_id) %></th>
<th><%= t("admin.actions.actions") %></th>
</tr>
</thead>
@@ -23,6 +24,9 @@
<% if budget.approval_voting? %>
<td><%= heading.max_ballot_lines %></td>
<% end %>
<td>
<%= geozone_for(heading) %>
</td>
<td>
<%= render Admin::TableActionsComponent.new(heading) %>
</td>

View File

@@ -14,4 +14,12 @@ class Admin::BudgetHeadings::HeadingsComponent < ApplicationComponent
def budget
@budget ||= group.budget
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

View File

@@ -1,3 +1,3 @@
<%= 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 %>

View File

@@ -4,4 +4,4 @@
<%= 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>
<%= render_map(nil, investments_coordinates: coordinates) %>
<%= render_map(nil, investments_coordinates: coordinates, geozones_data: geozones_data) %>
</div>

View File

@@ -13,8 +13,6 @@ class Budgets::MapComponent < ApplicationComponent
private
def coordinates
return unless budget.present?
if budget.publishing_prices_or_later? && budget.investments.selected.any?
investments = budget.investments.selected
else
@@ -23,4 +21,16 @@ class Budgets::MapComponent < ApplicationComponent
MapLocation.where(investment_id: investments).map(&:json_data)
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

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ module Admin::BudgetHeadingsActions
end
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)]
end

View File

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

View File

@@ -22,6 +22,7 @@ class Budget
end
belongs_to :group
belongs_to :geozone
has_many :investments
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 :debates
has_many :users
has_many :headings, class_name: "Budget::Heading", dependent: :nullify
validates :name, presence: true
validates :geojson, geojson_format: true
scope :public_for_api, -> { all }
@@ -17,4 +19,28 @@ class Geozone < ApplicationRecord
association.klass.where(geozone: self).empty?
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

View File

@@ -1,3 +1,7 @@
<%= 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 "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>
</div>
<%= render "form" %>
<%= render Admin::Geozones::FormComponent.new(@geozone) %>

View File

@@ -1,30 +1 @@
<%= link_to t("admin.geozones.index.create"),
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>
<%= render Admin::Geozones::IndexComponent.new(@geozones) %>

View File

@@ -4,4 +4,4 @@
<h2><%= t("admin.geozones.new.creating") %></h2>
</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>
<%= render Budgets::Investments::ContentBlocksComponent.new(@heading) %>
<% if @map_location&.available? %>
<%= render "budgets/investments/map" %>
<% end %>
<%= render Budgets::Investments::MapComponent.new(@investments_in_map, heading: @heading) %>
<%= render "shared/tag_cloud", taggable: "Budget::Investment" %>
<%= render "budgets/investments/categories" %>
<%= render Budgets::Investments::FiltersComponent.new %>

View File

@@ -201,7 +201,9 @@ en:
geozone:
name: Name
external_code: "External code (optional)"
color: "Color (optional)"
census_code: "Census code (optional)"
geojson: "GeoJSON data (optional)"
html_map_coordinates: "HTML <map> Coordinates (optional)"
milestone:
status_id: "Current status (optional)"
@@ -545,6 +547,10 @@ en:
attributes:
max_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:
attributes:
attachment:

View File

@@ -198,6 +198,7 @@ en:
success_notice: "Heading deleted successfully"
unable_notice: "You cannot delete a Heading that has associated investments"
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.'
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."
@@ -1391,8 +1392,13 @@ en:
external_code: External code
census_code: Census code
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
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:
form:
submit_button: Save changes
@@ -1404,6 +1410,8 @@ en:
delete:
success: Geozone successfully deleted
error: This geozone can't be deleted since there are elements attached to it
update:
notice: "Geozone updated successfully"
signature_sheets:
author: Author
created_at: Creation date

View File

@@ -200,8 +200,10 @@ es:
description: "Descripción"
geozone:
name: Nombre
color: "Color (opcional)"
external_code: "Código externo (opcional)"
census_code: "Código del censo (opcional)"
geojson: "Datos GeoJSON (opcional)"
html_map_coordinates: "Coordenadas HTML <map> (opcional)"
milestone:
status_id: "Estado actual (opcional)"
@@ -545,6 +547,10 @@ es:
attributes:
max_per_day:
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:
attributes:
attachment:

View File

@@ -198,6 +198,7 @@ es:
success_notice: "Partida presupuestaria eliminada correctamente"
unable_notice: "No se puede eliminar una partida presupuestaria con proyectos asociados"
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.'
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."
@@ -1391,8 +1392,13 @@ es:
external_code: Código externo
census_code: Código 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
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:
form:
submit_button: Guardar cambios
@@ -1404,6 +1410,8 @@ es:
delete:
success: Zona borrada correctamente
error: No se puede borrar la zona porque ya tiene elementos asociados
update:
notice: "Zona actualizada correctamente"
signature_sheets:
author: Autor
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.text "latitude"
t.text "longitude"
t.integer "geozone_id"
t.integer "max_ballot_lines", default: 1
t.datetime "created_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"
end
@@ -635,6 +637,8 @@ ActiveRecord::Schema.define(version: 2023_05_23_090028) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "census_code"
t.text "geojson"
t.string "color"
end
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 "budget_administrators", "administrators"
add_foreign_key "budget_administrators", "budgets"
add_foreign_key "budget_headings", "geozones"
add_foreign_key "budget_investments", "communities"
add_foreign_key "budget_valuators", "budgets"
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"
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
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).to have_css "strong", exact_text: "Whole planet"
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

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"
describe Budgets::MapComponent do
let(:budget) { build(:budget) }
before { Setting["feature.map"] = true }
let(:budget) { create(:budget, :accepting) }
describe "#render?" do
let(:budget) { build(:budget) }
it "is rendered after the informing phase when the map feature is enabled" do
Setting["feature.map"] = true
budget.phase = "accepting"
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
it "is not rendered during the informing phase" do
Setting["feature.map"] = true
budget.phase = "informing"
render_inline Budgets::MapComponent.new(budget)
@@ -31,4 +32,25 @@ describe Budgets::MapComponent do
expect(page).not_to be_rendered
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

View File

@@ -8,10 +8,19 @@ FactoryBot.define do
sequence(:name) { |n| "District #{n}" }
sequence(:external_code, &:to_s)
sequence(:census_code, &:to_s)
color { "#0081aa" }
trait :in_census do
census_code { "01" }
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
factory :banner do

View File

@@ -12,6 +12,19 @@ describe Geozone do
expect(geozone).not_to be_valid
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
let(:geozone) { create(:geozone) }
@@ -33,5 +46,34 @@ describe Geozone do
create(:debate, geozone: geozone)
expect(geozone).not_to be_safe_to_destroy
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

View File

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

View File

@@ -27,6 +27,7 @@ describe "Admin geozones", :admin do
click_button "Save changes"
expect(page).to have_content "Geozone created successfully"
expect(page).to have_content "Fancy District"
visit admin_geozones_path
@@ -46,6 +47,8 @@ describe "Admin geozones", :admin do
click_button "Save changes"
expect(page).to have_content "Geozone updated successfully"
within("#geozone_#{geozone.id}") do
expect(page).to have_content "New geozone name"
expect(page).to have_content "333"
@@ -64,6 +67,8 @@ describe "Admin geozones", :admin do
click_button "Save changes"
expect(page).to have_content "Geozone updated successfully"
within("#geozone_#{geozone.id}") do
expect(page).to have_content "New geozone name"
end
@@ -102,4 +107,39 @@ describe "Admin geozones", :admin do
expect(page).to have_content "Delete me!"
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

View File

@@ -1698,6 +1698,27 @@ describe "Budget Investments" do
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
stub_const("#{Budgets::InvestmentsController}::PER_PAGE", 2)