diff --git a/back/app/controllers/web_api/v1/custom_field_matrix_statements_controller.rb b/back/app/controllers/web_api/v1/custom_field_matrix_statements_controller.rb new file mode 100644 index 000000000000..d16830e4e8ca --- /dev/null +++ b/back/app/controllers/web_api/v1/custom_field_matrix_statements_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class WebApi::V1::CustomFieldMatrixStatementsController < ApplicationController + before_action :set_statement, only: %i[show] + before_action :set_custom_field, only: %i[index] + skip_before_action :authenticate_user + + def index + @statements = policy_scope(CustomFieldMatrixStatement).where(custom_field: @custom_field).order(:ordering) + render json: WebApi::V1::CustomFieldMatrixStatementSerializer.new(@statements, params: jsonapi_serializer_params).serializable_hash + end + + def show + render json: WebApi::V1::CustomFieldMatrixStatementSerializer.new(@statement, params: jsonapi_serializer_params).serializable_hash + end + + private + + def set_custom_field + @custom_field = CustomField.find(params[:custom_field_id]) + end + + def set_statement + @statement = CustomFieldMatrixStatement.find(params[:id]) + authorize @statement + end +end diff --git a/back/app/models/custom_field.rb b/back/app/models/custom_field.rb index be92a1c0db1f..a498af79ac79 100644 --- a/back/app/models/custom_field.rb +++ b/back/app/models/custom_field.rb @@ -48,6 +48,7 @@ class CustomField < ApplicationRecord acts_as_list column: :ordering, top_of_list: 0, scope: [:resource_id] has_many :options, -> { order(:ordering) }, dependent: :destroy, class_name: 'CustomFieldOption', inverse_of: :custom_field + has_many :matrix_statements, -> { order(:ordering) }, dependent: :destroy, class_name: 'CustomFieldMatrixStatement', inverse_of: :custom_field has_many :text_images, as: :imageable, dependent: :destroy accepts_nested_attributes_for :text_images @@ -59,7 +60,7 @@ class CustomField < ApplicationRecord INPUT_TYPES = %w[ checkbox date file_upload files html html_multiloc image_files linear_scale multiline_text multiline_text_multiloc multiselect multiselect_image number page point line polygon select select_image shapefile_upload text text_multiloc - topic_ids section cosponsor_ids ranking + topic_ids section cosponsor_ids ranking matrix_linear_scale ].freeze CODES = %w[ author_id birthyear body_multiloc budget domicile gender idea_files_attributes idea_images_attributes @@ -125,6 +126,20 @@ def supports_xlsx_export? %w[page section].exclude?(input_type) end + def supports_geojson? + return false if code == 'idea_images_attributes' # Is this still applicable? + + %w[page section].exclude?(input_type) + end + + def supports_linear_scale? + %w[linear_scale matrix_linear_scale].include?(input_type) + end + + def supports_matrix_statements? + input_type == 'matrix_linear_scale' + end + def average_rankings(scope) # This basically starts from all combinations of scope ID, option key (value) # and position (ordinality) and then calculates the average position for each @@ -338,7 +353,7 @@ def generate_key title = title_multiloc.values.first return unless title - self.key = CustomFieldService.new.generate_key(title, false) do |key_proposal| + self.key = CustomFieldService.new.generate_key(title) do |key_proposal| self.class.find_by(key: key_proposal, resource_type: resource_type) end end diff --git a/back/app/models/custom_field_matrix_statement.rb b/back/app/models/custom_field_matrix_statement.rb new file mode 100644 index 000000000000..3f08ac1d19c3 --- /dev/null +++ b/back/app/models/custom_field_matrix_statement.rb @@ -0,0 +1,42 @@ +# == Schema Information +# +# Table name: custom_field_matrix_statements +# +# id :uuid not null, primary key +# custom_field_id :uuid not null +# title_multiloc :jsonb not null +# key :string not null +# ordering :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_custom_field_matrix_statements_on_custom_field_id (custom_field_id) +# index_custom_field_matrix_statements_on_key (key) +# +# Foreign Keys +# +# fk_rails_... (custom_field_id => custom_fields.id) +# +class CustomFieldMatrixStatement < ApplicationRecord + # non-persisted attribute to enable form copying + attribute :temp_id, :string, default: nil + + belongs_to :custom_field + + before_validation :generate_key, on: :create + acts_as_list column: :ordering, top_of_list: 0, scope: :custom_field + + validates :title_multiloc, presence: true, multiloc: { presence: true } + validates :key, presence: true, uniqueness: { scope: [:custom_field_id] }, + format: { with: /\A[\w-]+\z/ } # Can only consist of word characters or dashes + validates :custom_field, presence: true + + private + + def generate_key + title = title_multiloc.values.first + self.key ||= title && CustomFieldService.new.generate_key(title) + end +end diff --git a/back/app/models/custom_field_option.rb b/back/app/models/custom_field_option.rb index 3c6062d50681..19af00df22d2 100644 --- a/back/app/models/custom_field_option.rb +++ b/back/app/models/custom_field_option.rb @@ -76,7 +76,7 @@ def generate_key title = title_multiloc.values.first return unless title - self.key = CustomFieldService.new.generate_key(title, other) do |key_proposal| + self.key = CustomFieldService.new.generate_key(title, other_option: other) do |key_proposal| self.class.find_by(key: key_proposal, custom_field: custom_field) end end diff --git a/back/app/policies/custom_field_matrix_statement_policy.rb b/back/app/policies/custom_field_matrix_statement_policy.rb new file mode 100644 index 000000000000..ef0a67d539de --- /dev/null +++ b/back/app/policies/custom_field_matrix_statement_policy.rb @@ -0,0 +1,11 @@ +class CustomFieldMatrixStatementPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + scope + end + end + + def show? + true + end +end diff --git a/back/app/serializers/web_api/v1/custom_field_matrix_statement_serializer.rb b/back/app/serializers/web_api/v1/custom_field_matrix_statement_serializer.rb new file mode 100644 index 000000000000..fa23bc0ba6f7 --- /dev/null +++ b/back/app/serializers/web_api/v1/custom_field_matrix_statement_serializer.rb @@ -0,0 +1,7 @@ +class WebApi::V1::CustomFieldMatrixStatementSerializer < WebApi::V1::BaseSerializer + attributes :key, :title_multiloc, :ordering, :created_at, :updated_at + + attribute :temp_id, if: proc { |object| + object.temp_id.present? + } +end diff --git a/back/app/serializers/web_api/v1/custom_field_serializer.rb b/back/app/serializers/web_api/v1/custom_field_serializer.rb index 2a8dbd5f1fe0..d266fc1a61a2 100644 --- a/back/app/serializers/web_api/v1/custom_field_serializer.rb +++ b/back/app/serializers/web_api/v1/custom_field_serializer.rb @@ -40,13 +40,16 @@ class WebApi::V1::CustomFieldSerializer < WebApi::V1::BaseSerializer :linear_scale_label_5_multiloc, :linear_scale_label_6_multiloc, :linear_scale_label_7_multiloc, - if: proc { |object, _params| object.linear_scale? } + if: proc { |object, _params| object.supports_linear_scale? } attributes :select_count_enabled, :maximum_select_count, :minimum_select_count, if: proc { |object, _params| object.multiselect? } has_many :options, record_type: :custom_field_option, serializer: ::WebApi::V1::CustomFieldOptionSerializer + has_many :matrix_statements, record_type: :custom_field_matrix_statement, serializer: ::WebApi::V1::CustomFieldMatrixStatementSerializer, if: proc { |field| + field.supports_matrix_statements? + } has_one :resource, record_type: :custom_form, serializer: ::WebApi::V1::CustomFormSerializer end diff --git a/back/app/services/custom_field_params_service.rb b/back/app/services/custom_field_params_service.rb index 8f66e5edbd08..deffb8ed608f 100644 --- a/back/app/services/custom_field_params_service.rb +++ b/back/app/services/custom_field_params_service.rb @@ -57,6 +57,8 @@ def supported_keys_in_custom_field_values(custom_field) %i[id content name] when 'html_multiloc', 'multiline_text_multiloc', 'text_multiloc' CL2_SUPPORTED_LOCALES + when 'matrix_linear_scale' + custom_field.matrix_statements.pluck(:key).map(&:to_sym) end end diff --git a/back/app/services/custom_field_service.rb b/back/app/services/custom_field_service.rb index 5d1e71649057..3d01e0ded043 100644 --- a/back/app/services/custom_field_service.rb +++ b/back/app/services/custom_field_service.rb @@ -75,8 +75,8 @@ def fields_to_ui_schema(fields, locale = 'en') end end - def generate_key(title, other) - return 'other' if other == true + def generate_key(title, other_option: false) + return 'other' if other_option == true keyify(title) end diff --git a/back/app/services/export/geojson/geojson_generator.rb b/back/app/services/export/geojson/geojson_generator.rb index 79d442a55e4c..86c29af9fabc 100644 --- a/back/app/services/export/geojson/geojson_generator.rb +++ b/back/app/services/export/geojson/geojson_generator.rb @@ -10,7 +10,7 @@ def initialize(phase, field) @phase = phase @field = field @inputs = phase.ideas.native_survey.published - @fields_in_form = IdeaCustomFieldsService.new(phase.custom_form).all_fields.filter(&:supports_xlsx_export?) + @fields_in_form = IdeaCustomFieldsService.new(phase.custom_form).geojson_supported_fields @multiloc_service = MultilocService.new(app_configuration: @app_configuration) @value_visitor = Geojson::ValueVisitor end diff --git a/back/app/services/export/xlsx/input_sheet_generator.rb b/back/app/services/export/xlsx/input_sheet_generator.rb index b4eb7c4066c9..a74a37e08ec5 100644 --- a/back/app/services/export/xlsx/input_sheet_generator.rb +++ b/back/app/services/export/xlsx/input_sheet_generator.rb @@ -73,6 +73,13 @@ def longitude_report_field ComputedFieldForReport.new(column_header_for('longitude'), ->(input) { input.location_point&.coordinates&.first }) end + def matrix_statement_report_field(statement) + ComputedFieldForReport.new( + multiloc_service.t(statement.title_multiloc), + ->(input) { input.custom_field_values.dig(statement.custom_field.key, statement.key) } + ) + end + def created_at_report_field ComputedFieldForReport.new(column_header_for('created_at'), ->(input) { input.created_at }) end @@ -144,6 +151,12 @@ def input_report_fields input_fields << latitude_report_field input_fields << longitude_report_field end + if field.input_type == 'matrix_linear_scale' + field.matrix_statements.each do |statement| + input_fields << matrix_statement_report_field(statement) + end + next + end input_fields << Export::CustomFieldForExport.new(field, @value_visitor) input_fields << Export::CustomFieldForExport.new(field.other_option_text_field, @value_visitor) if field.other_option_text_field end diff --git a/back/app/services/field_visitor_service.rb b/back/app/services/field_visitor_service.rb index 64600d7b680b..dc78e1673546 100644 --- a/back/app/services/field_visitor_service.rb +++ b/back/app/services/field_visitor_service.rb @@ -81,6 +81,10 @@ def visit_linear_scale(field) default(field) end + def visit_matrix_linear_scale(field) + default(field) + end + def visit_file_upload(field) default(field) end diff --git a/back/app/services/idea_custom_fields_service.rb b/back/app/services/idea_custom_fields_service.rb index c313d2eb7d3e..6409f9acc7dd 100644 --- a/back/app/services/idea_custom_fields_service.rb +++ b/back/app/services/idea_custom_fields_service.rb @@ -40,13 +40,13 @@ def submittable_fields_with_other_options # Used in the printable PDF export def printable_fields - ignore_field_types = %w[section page date files image_files point file_upload shapefile_upload topic_ids cosponsor_ids ranking] + ignore_field_types = %w[section page date files image_files point file_upload shapefile_upload topic_ids cosponsor_ids ranking matrix_linear_scale] fields = enabled_fields.reject { |field| ignore_field_types.include? field.input_type } insert_other_option_text_fields(fields) end def importable_fields - ignore_field_types = %w[page section date files image_files file_upload shapefile_upload point line polygon cosponsor_ids ranking] + ignore_field_types = %w[page section date files image_files file_upload shapefile_upload point line polygon cosponsor_ids ranking matrix_linear_scale] enabled_fields_with_other_options.reject { |field| ignore_field_types.include? field.input_type } end @@ -141,6 +141,13 @@ def duplicate_all_fields end copied_field.options = copied_options + # Duplicate statements + copied_field.matrix_statements = field.matrix_statements.map do |statement| + copied_statement = statement.dup + copied_statement.id = SecureRandom.uuid + copied_statement + end + # Duplicate and persist map config for custom_fields that can have an associated map_config if CustomField::MAP_CONFIG_INPUT_TYPES.include?(copied_field.input_type) && field.map_config original_map_config = CustomMaps::MapConfig.find(field.map_config.id) diff --git a/back/app/services/json_schema_generator_service.rb b/back/app/services/json_schema_generator_service.rb index 574af0f1b3d3..6b52c9350487 100644 --- a/back/app/services/json_schema_generator_service.rb +++ b/back/app/services/json_schema_generator_service.rb @@ -283,6 +283,21 @@ def visit_shapefile_upload(field) visit_file_upload(field) end + def visit_matrix_linear_scale(field) + { + type: 'object', + minProperties: (field.required ? field.matrix_statement_ids.size : 0), + maxProperties: field.matrix_statement_ids.size, + properties: field.matrix_statements.pluck(:key).map(&:to_sym).index_with do + { + type: 'number', + minimum: 1, + maximum: field.maximum + } + end + } + end + private attr_reader :locales, :multiloc_service diff --git a/back/app/services/side_fx_custom_field_matrix_statement_service.rb b/back/app/services/side_fx_custom_field_matrix_statement_service.rb new file mode 100644 index 000000000000..7e04d2bee317 --- /dev/null +++ b/back/app/services/side_fx_custom_field_matrix_statement_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class SideFxCustomFieldMatrixStatementService + include SideFxHelper + + def after_create(statement, current_user) + LogActivityJob.perform_later(statement, 'created', current_user, statement.created_at.to_i) + end + + def after_update(statement, current_user) + LogActivityJob.perform_later(statement, 'changed', current_user, statement.updated_at.to_i) + end + + def after_destroy(frozen_statement, current_user) + serialized_statement = clean_time_attributes(frozen_statement.attributes) + LogActivityJob.perform_later( + encode_frozen_resource(frozen_statement), + 'deleted', + current_user, + Time.now.to_i, + payload: { custom_field_matrix_statement: serialized_statement } + ) + end +end diff --git a/back/app/services/survey_results_generator_service.rb b/back/app/services/survey_results_generator_service.rb index 3aecfbff6fc0..e7c7f8086bfd 100644 --- a/back/app/services/survey_results_generator_service.rb +++ b/back/app/services/survey_results_generator_service.rb @@ -72,6 +72,13 @@ def visit_linear_scale(field) visit_select_base(field) end + def visit_matrix_linear_scale(field) + core_field_attributes(field).merge({ + multilocs: { answer: build_linear_scale_multilocs(field) }, + linear_scales: matrix_linear_scale_statements(field) + }) + end + def visit_file_upload(field) file_ids = inputs .select("custom_field_values->'#{field.key}'->'id' as value") @@ -115,7 +122,7 @@ def core_field_attributes(field, response_count: nil) customFieldId: field.id, required: field.required, grouped: !!group_field_id, - totalResponseCount: @inputs.count, + totalResponseCount: @inputs.size, questionResponseCount: response_count } end @@ -131,15 +138,8 @@ def base_responses(field) def visit_select_base(field) query = inputs - if group_field_id - if group_mode == 'user_field' - # Single user field grouped result - group_field = CustomField.find(group_field_id) - query = query.joins(:author) - else - # Single form field grouped result - group_field = find_question(group_field_id) - end + query = query.joins(:author) if group_mode == 'user_field' + if group_field raise "Unsupported group field type: #{group_field.input_type}" unless %w[select linear_scale].include?(group_field.input_type) raise "Unsupported question type: #{field.input_type}" unless %w[select multiselect linear_scale multiselect_image].include?(field.input_type) @@ -147,7 +147,7 @@ def visit_select_base(field) select_field_query(field, as: 'answer'), select_field_query(group_field, as: 'group') ) - answers = construct_grouped_answers(query, field, group_field) + answers = construct_grouped_answers(query, field) else query = query.select( select_field_query(field, as: 'answer') @@ -160,7 +160,7 @@ def visit_select_base(field) answers = answers.sort_by { |a| a[:answer] == 'other' ? 1 : 0 } # other should always be last # Build response - build_select_response(answers, field, group_field) + build_select_response(answers, field) end def select_field_query(field, as: 'answer') @@ -181,6 +181,26 @@ def select_field_query(field, as: 'answer') end end + def matrix_linear_scale_statements(field) + field.matrix_statements.pluck(:key, :title_multiloc).to_h do |statement_key, statement_title_multiloc| + query_result = inputs.group("custom_field_values->'#{field.key}'->'#{statement_key}'").count + answers = (1..field.maximum).reverse_each.map do |answer| + { answer: answer, count: query_result[answer] || 0 } + end + question_response_count = answers.sum { |a| a[:count] } + answers.each do |answer| + answer[:percentage] = question_response_count > 0 ? (answer[:count].to_f / question_response_count) : 0.0 + end + answers += [{ answer: nil, count: query_result[nil] || 0 }] + value = { + question: statement_title_multiloc, + questionResponseCount: question_response_count, + answers: + } + [statement_key, value] + end + end + def responses_to_geographic_input_type(field) responses = base_responses(field) response_count = responses.size @@ -189,23 +209,23 @@ def responses_to_geographic_input_type(field) }) end - def build_select_response(answers, field, group_field) + def build_select_response(answers, field) # TODO: This is an additional query for selects so performance issue here question_response_count = inputs.where("custom_field_values->'#{field.key}' IS NOT NULL").count attributes = core_field_attributes(field, response_count: question_response_count).merge({ totalPickCount: answers.pluck(:count).sum, answers: answers, - multilocs: get_multilocs(field, group_field) + multilocs: get_multilocs(field) }) attributes[:textResponses] = get_text_responses("#{field.key}_other") if field.other_option_text_field - attributes[:legend] = generate_answer_keys(group_field) if group_field.present? + attributes[:legend] = generate_answer_keys(group_field) if group_field attributes end - def get_multilocs(field, group_field = nil) + def get_multilocs(field) multilocs = { answer: get_option_multilocs(field) } multilocs[:group] = get_option_multilocs(group_field) if group_field multilocs @@ -254,7 +274,7 @@ def construct_not_grouped_answers(query, field) end end - def construct_grouped_answers(query, question_field, group_field) + def construct_grouped_answers(query, question_field) answer_keys = generate_answer_keys(question_field) group_field_keys = generate_answer_keys(group_field) @@ -334,4 +354,16 @@ def build_linear_scale_multilocs(field) answer_titles end + + def group_field + @group_field ||= if group_field_id + if group_mode == 'user_field' + CustomField.find(group_field_id) + else + find_question(group_field_id) + end + else + false + end + end end diff --git a/back/app/services/ui_schema_generator_service.rb b/back/app/services/ui_schema_generator_service.rb index 8a1c6bb3028e..7426a7f7cbd4 100644 --- a/back/app/services/ui_schema_generator_service.rb +++ b/back/app/services/ui_schema_generator_service.rb @@ -91,6 +91,14 @@ def visit_linear_scale(field) end end + def visit_matrix_linear_scale(field) + visit_linear_scale(field).tap do |ui_field| + ui_field[:options][:statements] = field.matrix_statements.map do |statement| + { key: statement.key, label: multiloc_service.t(statement.title_multiloc) } + end + end + end + def visit_select(field) default(field).tap do |ui_field| ui_field[:options][:enumNames] = field.ordered_options.map { |option| multiloc_service.t(option.title_multiloc) } diff --git a/back/db/migrate/20250117121004_create_custom_field_matrix_statements.rb b/back/db/migrate/20250117121004_create_custom_field_matrix_statements.rb new file mode 100644 index 000000000000..2c5626232c30 --- /dev/null +++ b/back/db/migrate/20250117121004_create_custom_field_matrix_statements.rb @@ -0,0 +1,12 @@ +class CreateCustomFieldMatrixStatements < ActiveRecord::Migration[7.0] + def change + create_table :custom_field_matrix_statements, id: :uuid do |t| + t.references :custom_field, foreign_key: true, null: false, type: :uuid, index: true + t.jsonb :title_multiloc, default: {}, null: false + t.string :key, null: false, index: true + t.integer :ordering, null: false + + t.timestamps + end + end +end diff --git a/back/db/structure.sql b/back/db/structure.sql index 4a10bb37f96e..c48f9b387418 100644 --- a/back/db/structure.sql +++ b/back/db/structure.sql @@ -42,6 +42,7 @@ ALTER TABLE IF EXISTS ONLY public.reactions DROP CONSTRAINT IF EXISTS fk_rails_c ALTER TABLE IF EXISTS ONLY public.idea_import_files DROP CONSTRAINT IF EXISTS fk_rails_c93392afae; ALTER TABLE IF EXISTS ONLY public.email_campaigns_deliveries DROP CONSTRAINT IF EXISTS fk_rails_c87ec11171; ALTER TABLE IF EXISTS ONLY public.notifications DROP CONSTRAINT IF EXISTS fk_rails_c76d81b062; +ALTER TABLE IF EXISTS ONLY public.custom_field_matrix_statements DROP CONSTRAINT IF EXISTS fk_rails_c379cdcd80; ALTER TABLE IF EXISTS ONLY public.idea_images DROP CONSTRAINT IF EXISTS fk_rails_c349bb4ac3; ALTER TABLE IF EXISTS ONLY public.ideas DROP CONSTRAINT IF EXISTS fk_rails_c32c787647; ALTER TABLE IF EXISTS ONLY public.project_files DROP CONSTRAINT IF EXISTS fk_rails_c26fbba4b3; @@ -331,6 +332,8 @@ DROP INDEX IF EXISTS public.index_custom_fields_on_resource_type_and_resource_id DROP INDEX IF EXISTS public.index_custom_field_options_on_custom_field_id_and_key; DROP INDEX IF EXISTS public.index_custom_field_options_on_custom_field_id; DROP INDEX IF EXISTS public.index_custom_field_option_images_on_custom_field_option_id; +DROP INDEX IF EXISTS public.index_custom_field_matrix_statements_on_key; +DROP INDEX IF EXISTS public.index_custom_field_matrix_statements_on_custom_field_id; DROP INDEX IF EXISTS public.index_cosponsorships_on_user_id; DROP INDEX IF EXISTS public.index_cosponsorships_on_idea_id; DROP INDEX IF EXISTS public.index_cosponsors_initiatives_on_user_id; @@ -494,6 +497,7 @@ ALTER TABLE IF EXISTS ONLY public.custom_forms DROP CONSTRAINT IF EXISTS custom_ ALTER TABLE IF EXISTS ONLY public.custom_fields DROP CONSTRAINT IF EXISTS custom_fields_pkey; ALTER TABLE IF EXISTS ONLY public.custom_field_options DROP CONSTRAINT IF EXISTS custom_field_options_pkey; ALTER TABLE IF EXISTS ONLY public.custom_field_option_images DROP CONSTRAINT IF EXISTS custom_field_option_images_pkey; +ALTER TABLE IF EXISTS ONLY public.custom_field_matrix_statements DROP CONSTRAINT IF EXISTS custom_field_matrix_statements_pkey; ALTER TABLE IF EXISTS ONLY public.cosponsorships DROP CONSTRAINT IF EXISTS cosponsorships_pkey; ALTER TABLE IF EXISTS ONLY public.cosponsors_initiatives DROP CONSTRAINT IF EXISTS cosponsors_initiatives_pkey; ALTER TABLE IF EXISTS ONLY public.content_builder_layouts DROP CONSTRAINT IF EXISTS content_builder_layouts_pkey; @@ -602,6 +606,7 @@ DROP TABLE IF EXISTS public.custom_forms; DROP TABLE IF EXISTS public.custom_fields; DROP TABLE IF EXISTS public.custom_field_options; DROP TABLE IF EXISTS public.custom_field_option_images; +DROP TABLE IF EXISTS public.custom_field_matrix_statements; DROP TABLE IF EXISTS public.cosponsorships; DROP TABLE IF EXISTS public.cosponsors_initiatives; DROP TABLE IF EXISTS public.content_builder_layouts; @@ -2078,6 +2083,21 @@ CREATE TABLE public.cosponsorships ( ); +-- +-- Name: custom_field_matrix_statements; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.custom_field_matrix_statements ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + custom_field_id uuid NOT NULL, + title_multiloc jsonb DEFAULT '{}'::jsonb NOT NULL, + key character varying NOT NULL, + ordering integer NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + -- -- Name: custom_field_option_images; Type: TABLE; Schema: public; Owner: - -- @@ -3586,6 +3606,14 @@ ALTER TABLE ONLY public.cosponsorships ADD CONSTRAINT cosponsorships_pkey PRIMARY KEY (id); +-- +-- Name: custom_field_matrix_statements custom_field_matrix_statements_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.custom_field_matrix_statements + ADD CONSTRAINT custom_field_matrix_statements_pkey PRIMARY KEY (id); + + -- -- Name: custom_field_option_images custom_field_option_images_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4818,6 +4846,20 @@ CREATE INDEX index_cosponsorships_on_idea_id ON public.cosponsorships USING btre CREATE INDEX index_cosponsorships_on_user_id ON public.cosponsorships USING btree (user_id); +-- +-- Name: index_custom_field_matrix_statements_on_custom_field_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_custom_field_matrix_statements_on_custom_field_id ON public.custom_field_matrix_statements USING btree (custom_field_id); + + +-- +-- Name: index_custom_field_matrix_statements_on_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_custom_field_matrix_statements_on_key ON public.custom_field_matrix_statements USING btree (key); + + -- -- Name: index_custom_field_option_images_on_custom_field_option_id; Type: INDEX; Schema: public; Owner: - -- @@ -6942,6 +6984,14 @@ ALTER TABLE ONLY public.idea_images ADD CONSTRAINT fk_rails_c349bb4ac3 FOREIGN KEY (idea_id) REFERENCES public.ideas(id); +-- +-- Name: custom_field_matrix_statements fk_rails_c379cdcd80; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.custom_field_matrix_statements + ADD CONSTRAINT fk_rails_c379cdcd80 FOREIGN KEY (custom_field_id) REFERENCES public.custom_fields(id); + + -- -- Name: notifications fk_rails_c76d81b062; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -7695,6 +7745,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20241227103433'), ('20241230165323'), ('20241230165518'), -('20241230172612'); +('20241230172612'), +('20250117121004'); diff --git a/back/engines/commercial/analysis/app/services/analysis/input_to_text.rb b/back/engines/commercial/analysis/app/services/analysis/input_to_text.rb index 7f570f225bd7..1b3fd05a23df 100644 --- a/back/engines/commercial/analysis/app/services/analysis/input_to_text.rb +++ b/back/engines/commercial/analysis/app/services/analysis/input_to_text.rb @@ -84,15 +84,11 @@ def input_field_value(input, custom_field) options_by_key = custom_field.options.index_by(&:key) value_for_llm = case custom_field.input_type when 'ranking' - stored_value = input.custom_field_values[custom_field.key] - (stored_value || []).map.with_index do |option_key, index| - title_multiloc = options_by_key[option_key]&.title_multiloc - option_title = title_multiloc ? @multiloc_service.t(title_multiloc) : '' - "#{index + 1}. #{option_title}" - end.join("\n") + ranking_field_value(input, custom_field, options_by_key) + when 'matrix_linear_scale' + matrix_linear_scale_field_value(input, custom_field) else - vv = Export::Xlsx::ValueVisitor.new(input, options_by_key, app_configuration: @app_configuration) - custom_field.accept(vv) + xlsx_field_value(input, custom_field, options_by_key) end @memoized_field_values[input.id][custom_field.id] = value_for_llm @@ -106,5 +102,33 @@ def add_field(field, input, obj, truncate_values: nil, override_field_labels: {} value = truncate_values ? full_value&.truncate(truncate_values) : full_value obj[label] = value end + + def ranking_field_value(input, custom_field, options_by_key) + stored_value = input.custom_field_values[custom_field.key] + (stored_value || []).map.with_index do |option_key, index| + title_multiloc = options_by_key[option_key]&.title_multiloc + option_title = title_multiloc ? @multiloc_service.t(title_multiloc) : '' + "#{index + 1}. #{option_title}" + end.join("\n") + end + + def matrix_linear_scale_field_value(input, custom_field) + stored_values = input.custom_field_values[custom_field.key] + return '' if !stored_values + + statements_by_key = custom_field.matrix_statements.index_by(&:key) + stored_values.map do |statement_key, value| + title_multiloc = statements_by_key[statement_key]&.title_multiloc + statement_title = title_multiloc ? @multiloc_service.t(title_multiloc) : '' + label_multiloc = custom_field.nth_linear_scale_multiloc(value) + value_str = label_multiloc.present? ? "#{value} (#{@multiloc_service.t(label_multiloc)})" : value.to_s + "#{statement_title} - #{value_str}" + end.join("\n") + end + + def xlsx_field_value(input, custom_field, options_by_key) + vv = Export::Xlsx::ValueVisitor.new(input, options_by_key, app_configuration: @app_configuration) + custom_field.accept(vv) + end end end diff --git a/back/engines/commercial/analysis/spec/services/input_to_text_spec.rb b/back/engines/commercial/analysis/spec/services/input_to_text_spec.rb index cb979503821c..84a207487427 100644 --- a/back/engines/commercial/analysis/spec/services/input_to_text_spec.rb +++ b/back/engines/commercial/analysis/spec/services/input_to_text_spec.rb @@ -200,4 +200,25 @@ ) end end + + it 'generates multiple lines for matrix linear scale fields' do + custom_field = create(:custom_field_matrix_linear_scale) + create(:custom_field_matrix_statement, custom_field: custom_field, key: 'more_benches_in_park', title_multiloc: { en: 'We need more benches in the park' }) + service = described_class.new([custom_field.reload]) + + input = build( + :idea, + custom_field_values: { + custom_field.key => { 'send_more_animals_to_space' => 5, 'more_benches_in_park' => 2 } + } + ) + + expect(service.formatted(input)).to eq( + <<~TEXT + ### Please indicate how strong you agree or disagree with the following statements. + We should send more animals into space - 5 (Strongly agree) + We need more benches in the park - 2 + TEXT + ) + end end diff --git a/back/engines/commercial/idea_custom_fields/app/controllers/idea_custom_fields/web_api/v1/admin/idea_custom_fields_controller.rb b/back/engines/commercial/idea_custom_fields/app/controllers/idea_custom_fields/web_api/v1/admin/idea_custom_fields_controller.rb index 39911bae588c..c83a1816a2c3 100644 --- a/back/engines/commercial/idea_custom_fields/app/controllers/idea_custom_fields/web_api/v1/admin/idea_custom_fields_controller.rb +++ b/back/engines/commercial/idea_custom_fields/app/controllers/idea_custom_fields/web_api/v1/admin/idea_custom_fields_controller.rb @@ -94,7 +94,7 @@ def custom_form # Extended by CustomMaps::Patches::IdeaCustomFields::WebApi::V1::Admin::IdeaCustomFieldsController def include_in_index_response - %i[options options.image resource] + %i[options options.image matrix_statements resource] end def raise_error_if_not_geographic_field @@ -128,6 +128,7 @@ def update_fields!(page_temp_ids_to_ids_mapping, option_temp_ids_to_ids_mapping, delete_fields.each { |field| delete_field! field } given_fields.each_with_index do |field_params, index| options_params = field_params.delete :options + statements_params = field_params.delete :matrix_statements if field_params[:id] && fields_by_id.key?(field_params[:id]) field = fields_by_id[field_params[:id]] next unless update_field!(field, field_params, errors, index) @@ -139,6 +140,7 @@ def update_fields!(page_temp_ids_to_ids_mapping, option_temp_ids_to_ids_mapping, option_temp_ids_to_ids_mapping_in_field_logic = update_options! field, options_params, errors, index option_temp_ids_to_ids_mapping.merge! option_temp_ids_to_ids_mapping_in_field_logic end + update_statements! field, statements_params, errors, index if statements_params relate_map_config_to_field(field, field_params, errors, index) field.set_list_position(index) count_fields(field) @@ -290,6 +292,61 @@ def delete_option!(option) option end + def add_options_errors(options_errors, errors, field_index, option_index) + errors[field_index.to_s] ||= {} + errors[field_index.to_s][:options] ||= {} + errors[field_index.to_s][:options][option_index.to_s] = options_errors + end + + def update_statements!(field, statements_params, errors, field_index) + statements = field.matrix_statements + statements_by_id = statements.index_by(&:id) + given_ids = statements_params.pluck :id + + deleted_statements = statements.reject { |statement| given_ids.include? statement.id } + deleted_statements.each { |statement| delete_statement! statement } + statements_params.each_with_index do |statement_params, statement_index| + statement_params[:ordering] = statement_index # Do this instead of ordering gem .move_to_bottom method for performance reasons + if statement_params[:id] && statements_by_id[statement_params[:id]] + statement = statements_by_id[statement_params[:id]] + update_statement! statement, statement_params, errors, field_index, statement_index + else + create_statement! statement_params, field, errors, field_index, statement_index + end + end + end + + def create_statement!(statement_params, field, errors, field_index, statement_index) + statement = CustomFieldMatrixStatement.new statement_params.merge(custom_field: field) + if statement.save + SideFxCustomFieldMatrixStatementService.new.after_create statement, current_user + else + add_statements_errors statement.errors.details, errors, field_index, statement_index + end + end + + def update_statement!(statement, statement_params, errors, field_index, statement_index) + statement.assign_attributes statement_params + return if !statement.changed? + + if statement.save + SideFxCustomFieldMatrixStatementService.new.after_update statement, current_user + else + add_statements_errors statement.errors.details, errors, field_index, statement_index + end + end + + def delete_statement!(statement) + statement.destroy! + SideFxCustomFieldMatrixStatementService.new.after_destroy statement, current_user + end + + def add_statements_errors(statements_errors, errors, field_index, statement_index) + errors[field_index.to_s] ||= {} + errors[field_index.to_s][:statements] ||= {} + errors[field_index.to_s][:statements][statement_index.to_s] = statements_errors + end + def update_logic!(page_temp_ids_to_ids_mapping, option_temp_ids_to_ids_mapping, errors) fields = IdeaCustomFieldsService.new(@custom_form).all_fields form_logic = FormLogicService.new fields @@ -333,12 +390,6 @@ def count_fields(field) end end - def add_options_errors(options_errors, errors, field_index, option_index) - errors[field_index.to_s] ||= {} - errors[field_index.to_s][:options] ||= {} - errors[field_index.to_s][:options][option_index.to_s] = options_errors - end - def update_all_params params.permit(:form_last_updated_at, :form_opened_at, :form_save_type, custom_fields: [ :id, @@ -375,6 +426,12 @@ def update_all_params title_multiloc: CL2_SUPPORTED_LOCALES } ], + matrix_statements: [ + :id, + :key, + :temp_id, + { title_multiloc: CL2_SUPPORTED_LOCALES } + ], logic: {} } ]) end diff --git a/back/engines/commercial/idea_custom_fields/config/routes.rb b/back/engines/commercial/idea_custom_fields/config/routes.rb index 6fd93636615a..271d5fe3a069 100644 --- a/back/engines/commercial/idea_custom_fields/config/routes.rb +++ b/back/engines/commercial/idea_custom_fields/config/routes.rb @@ -14,6 +14,7 @@ patch 'update_all', on: :collection get 'custom_form', on: :collection resources :custom_field_options, controller: '/web_api/v1/custom_field_options', only: %i[show] + resources :custom_field_matrix_statements, controller: '/web_api/v1/custom_field_matrix_statements', only: %i[index show] end end resources :phases, only: [] do @@ -27,6 +28,7 @@ get 'custom_form', on: :collection get :as_geojson, on: :member, action: 'as_geojson' resources :custom_field_options, controller: '/web_api/v1/custom_field_options', only: %i[show] + resources :custom_field_matrix_statements, controller: '/web_api/v1/custom_field_matrix_statements', only: %i[index show] end end end diff --git a/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/custom_field_matrix_statements_spec.rb b/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/custom_field_matrix_statements_spec.rb new file mode 100644 index 000000000000..d54f7ca93e90 --- /dev/null +++ b/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/custom_field_matrix_statements_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' +require 'rspec_api_documentation/dsl' + +resource 'Idea Custom Field Matrix Statements' do + explanation 'Options for each custom field used in ideas and native surveys.' + + before { header 'Content-Type', 'application/json' } + + let(:custom_field) { create(:custom_field_matrix_linear_scale, resource: custom_form) } + let(:custom_field_id) { custom_field.id } + let(:statement) { custom_field.matrix_statements.first } + let(:id) { statement.id } + + context 'transitive (ideation or proposals)' do + let(:project) { create(:project) } + let(:custom_form) { create(:custom_form, participation_context: project) } + let(:project_id) { project.id } + + get 'web_api/v1/admin/projects/:project_id/custom_fields/:custom_field_id/custom_field_matrix_statements' do + context 'when admin' do + before { admin_header_token } + + example_request 'Get all matrix statements for a given custom field' do + assert_status 200 + expect(response_data.size).to eq 2 + expect(response_data.pluck(:id)).to eq custom_field.matrix_statement_ids + expect(response_data.pluck(attributes: :title_multiloc)).to eq custom_field.matrix_statements.pluck(&:title_multiloc) + end + end + end + + get 'web_api/v1/admin/projects/:project_id/custom_fields/:custom_field_id/custom_field_matrix_statements/:id' do + context 'when admin' do + before { admin_header_token } + + example_request 'Get one matrix statement by id' do + assert_status 200 + expect(response_data[:type]).to eq 'custom_field_matrix_statement' + expect(response_data[:id]).to eq id + expect(response_data[:attributes][:title_multiloc].stringify_keys).to eq statement.title_multiloc + end + end + end + end + + context 'non-transitive (native surveys)' do + let(:phase) { create(:native_survey_phase) } + let(:custom_form) { create(:custom_form, participation_context: phase) } + let(:phase_id) { phase.id } + + get 'web_api/v1/admin/phases/:phase_id/custom_fields/:custom_field_id/custom_field_matrix_statements' do + context 'when admin' do + before { admin_header_token } + + example_request 'Get all matrix statements for a given custom field' do + assert_status 200 + expect(response_data.size).to eq 2 + expect(response_data.pluck(:id)).to eq custom_field.matrix_statement_ids + expect(response_data.pluck(attributes: :title_multiloc)).to eq custom_field.matrix_statements.pluck(&:title_multiloc) + end + end + end + + get 'web_api/v1/admin/phases/:phase_id/custom_fields/:custom_field_id/custom_field_matrix_statements/:id' do + context 'when admin' do + before { admin_header_token } + + example_request 'Get one matrix statement by id' do + assert_status 200 + expect(response_data[:type]).to eq 'custom_field_matrix_statement' + expect(response_data[:id]).to eq id + expect(response_data[:attributes][:title_multiloc].stringify_keys).to eq statement.title_multiloc + end + end + end + end +end diff --git a/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/phase_context/update_all_native_survey_spec.rb b/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/phase_context/update_all_native_survey_spec.rb index f0daa458f9f8..f574b867ba7e 100644 --- a/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/phase_context/update_all_native_survey_spec.rb +++ b/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/phase_context/update_all_native_survey_spec.rb @@ -26,7 +26,7 @@ end let(:context) { create(:native_survey_phase) } - let(:custom_form) { create(:custom_form, participation_context: context) } + let!(:custom_form) { create(:custom_form, participation_context: context) } let(:phase_id) { context.id } context 'when admin' do @@ -282,7 +282,7 @@ } ] }, - resource: { data: { id: an_instance_of(String), type: 'custom_form' } } + resource: { data: { id: custom_form.id, type: 'custom_form' } } } }) options = CustomField.find(json_response.dig(:data, 1, :id)).options @@ -396,6 +396,205 @@ ) end + example 'Add a matrix linear scale field with statements' do + request = { + custom_fields: [ + { + input_type: 'page', + page_layout: 'default' + }, + { + input_type: 'matrix_linear_scale', + title_multiloc: { en: 'Inserted field' }, + required: false, + enabled: true, + linear_scale_label_1_multiloc: { 'en' => 'Closest' }, + linear_scale_label_7_multiloc: { 'en' => 'Furthest' }, + matrix_statements: [ + { + title_multiloc: { en: 'Statement 1' } + }, + { + title_multiloc: { en: 'Statement 2' } + } + ] + } + ] + } + do_request request + + assert_status 200 + json_response = json_parse response_body + + expect(json_response[:data].size).to eq 2 + expect(json_response[:data][1]).to match({ + attributes: { + code: nil, + created_at: an_instance_of(String), + description_multiloc: {}, + enabled: true, + input_type: 'matrix_linear_scale', + key: Regexp.new('inserted_field'), + ordering: 1, + required: false, + title_multiloc: { en: 'Inserted field' }, + updated_at: an_instance_of(String), + logic: {}, + constraints: {}, + random_option_ordering: false, + linear_scale_label_1_multiloc: { en: 'Closest' }, + linear_scale_label_2_multiloc: {}, + linear_scale_label_3_multiloc: {}, + linear_scale_label_4_multiloc: {}, + linear_scale_label_5_multiloc: {}, + linear_scale_label_6_multiloc: {}, + linear_scale_label_7_multiloc: { en: 'Furthest' }, + maximum: nil + }, + id: an_instance_of(String), + type: 'custom_field', + relationships: { + matrix_statements: { + data: [ + { + id: an_instance_of(String), + type: 'custom_field_matrix_statement' + }, + { + id: an_instance_of(String), + type: 'custom_field_matrix_statement' + } + ] + }, + options: { data: [] }, + resource: { data: { id: custom_form.id, type: 'custom_form' } } + } + }) + statements = CustomField.find(json_response.dig(:data, 1, :id)).matrix_statements + json_statement1 = json_response[:included].find do |json_statement| + json_statement[:id] == statements.first.id + end + json_statement2 = json_response[:included].find do |json_statement| + json_statement[:id] == statements.last.id + end + expect(json_statement1).to match({ + id: statements.first.id, + type: 'custom_field_matrix_statement', + attributes: { + key: an_instance_of(String), + title_multiloc: { en: 'Statement 1' }, + ordering: 0, + created_at: an_instance_of(String), + updated_at: an_instance_of(String) + } + }) + expect(json_statement2).to match({ + id: statements.last.id, + type: 'custom_field_matrix_statement', + attributes: { + key: an_instance_of(String), + title_multiloc: { en: 'Statement 2' }, + ordering: 1, + created_at: an_instance_of(String), + updated_at: an_instance_of(String) + } + }) + end + + example 'Update a matrix linear scale field, add, delete and update statements' do + field_to_update = create( + :custom_field_matrix_linear_scale, + resource: custom_form, + linear_scale_label_5_multiloc: { 'en' => 'Furthest' } + ) + update_statement_id, delete_statement_id = field_to_update.matrix_statement_ids + + request = { + custom_fields: [ + { + input_type: 'page', + page_layout: 'default' + }, + { + id: field_to_update.id, + title_multiloc: { en: 'Updated field' }, + linear_scale_label_5_multiloc: { 'en' => 'Farthest' }, + maximum: 5, + matrix_statements: [ + { + title_multiloc: { en: 'Inserted statement' } + }, + { + id: update_statement_id, + title_multiloc: { en: 'Updated statement' } + } + ] + } + ] + } + do_request request + + assert_status 200 + json_response = json_parse response_body + + expect(json_response[:data].size).to eq 2 + expect(json_response[:data][1]).to match({ + attributes: hash_including( + input_type: 'matrix_linear_scale', + title_multiloc: { en: 'Updated field' }, + linear_scale_label_5_multiloc: { en: 'Farthest' }, + maximum: 5 + ), + id: an_instance_of(String), + type: 'custom_field', + relationships: { + matrix_statements: { + data: [ + { + id: an_instance_of(String), + type: 'custom_field_matrix_statement' + }, + { + id: update_statement_id, + type: 'custom_field_matrix_statement' + } + ] + }, + options: { data: [] }, + resource: { data: { id: custom_form.id, type: 'custom_form' } } + } + }) + json_insert_statement = json_response[:included].find do |json_statement| + json_statement[:id] != update_statement_id && json_statement[:type] == 'custom_field_matrix_statement' + end + json_update_statement = json_response[:included].find do |json_statement| + json_statement[:id] == update_statement_id + end + expect(json_insert_statement).to match({ + id: an_instance_of(String), + type: 'custom_field_matrix_statement', + attributes: { + key: an_instance_of(String), + title_multiloc: { en: 'Inserted statement' }, + ordering: 0, + created_at: an_instance_of(String), + updated_at: an_instance_of(String) + } + }) + expect(json_update_statement).to match({ + id: update_statement_id, + type: 'custom_field_matrix_statement', + attributes: { + key: an_instance_of(String), + title_multiloc: { en: 'Updated statement' }, + ordering: 1, + created_at: an_instance_of(String), + updated_at: an_instance_of(String) + } + }) + expect(field_to_update.reload.matrix_statement_ids).not_to include(delete_statement_id) + end + example '[error] Add a field of unsupported input_type' do request = { custom_fields: [ diff --git a/back/lib/participation_method/ideation.rb b/back/lib/participation_method/ideation.rb index 8702877b41e4..4f2cf27f34aa 100644 --- a/back/lib/participation_method/ideation.rb +++ b/back/lib/participation_method/ideation.rb @@ -11,7 +11,7 @@ def additional_export_columns end def allowed_extra_field_input_types - %w[section number linear_scale text multiline_text select multiselect multiselect_image ranking] + %w[section number linear_scale text multiline_text select multiselect multiselect_image ranking matrix_linear_scale] end def allowed_ideas_orders diff --git a/back/lib/participation_method/native_survey.rb b/back/lib/participation_method/native_survey.rb index 067aa0cea03b..e249ade44e43 100644 --- a/back/lib/participation_method/native_survey.rb +++ b/back/lib/participation_method/native_survey.rb @@ -9,7 +9,7 @@ def self.method_str def allowed_extra_field_input_types %w[page number linear_scale text multiline_text select multiselect multiselect_image file_upload shapefile_upload point line polygon - ranking] + ranking matrix_linear_scale] end def assign_defaults(input) @@ -51,8 +51,7 @@ def default_fields(custom_form) CustomField.new( id: SecureRandom.uuid, key: CustomFieldService.new.generate_key( - multiloc_service.i18n_to_multiloc('form_builder.default_select_field.title').values.first, - false + multiloc_service.i18n_to_multiloc('form_builder.default_select_field.title').values.first ), resource: custom_form, input_type: 'select', diff --git a/back/spec/factories/custom_field_matrix_statements.rb b/back/spec/factories/custom_field_matrix_statements.rb new file mode 100644 index 000000000000..dd3be12654f7 --- /dev/null +++ b/back/spec/factories/custom_field_matrix_statements.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :custom_field_matrix_statement do + custom_field + sequence(:key) { |n| "statement_#{n}" } + title_multiloc do + { + 'en' => 'We should send more animals into space' + } + end + end +end diff --git a/back/spec/factories/custom_fields.rb b/back/spec/factories/custom_fields.rb index 3d64ca28646a..1918bfc93d9d 100644 --- a/back/spec/factories/custom_fields.rb +++ b/back/spec/factories/custom_fields.rb @@ -283,6 +283,33 @@ end end + factory :custom_field_matrix_linear_scale do + title_multiloc do + { + 'en' => 'Please indicate how strong you agree or disagree with the following statements.' + } + end + input_type { 'matrix_linear_scale' } + maximum { 5 } + linear_scale_label_1_multiloc do + { + 'en' => 'Strongly disagree' + } + end + linear_scale_label_5_multiloc do + { + 'en' => 'Strongly agree' + } + end + + matrix_statements do + [ + build(:custom_field_matrix_statement, title_multiloc: { 'en' => 'We should send more animals into space' }, key: 'send_more_animals_to_space'), + build(:custom_field_matrix_statement, title_multiloc: { 'en' => 'We should ride our bicycles more often' }, key: 'ride_bicycles_more_often') + ] + end + end + factory :custom_field_birthyear do key { 'birthyear' } title_multiloc { { en: 'birthyear' } } diff --git a/back/spec/models/custom_field_spec.rb b/back/spec/models/custom_field_spec.rb index 93fa00660c3a..7bd3fa23fa71 100644 --- a/back/spec/models/custom_field_spec.rb +++ b/back/spec/models/custom_field_spec.rb @@ -79,6 +79,10 @@ def visit_linear_scale(_field) 'linear_scale from visitor' end + def visit_matrix_linear_scale(_field) + 'matrix_linear_scale from visitor' + end + def visit_page(_field) 'page from visitor' end @@ -111,6 +115,15 @@ def visit_ranking(_field) RSpec.describe CustomField do let(:field) { described_class.new input_type: 'not_important_for_this_test' } + describe 'factories' do + it 'create a valid matrix linear scale field' do + field = create(:custom_field_matrix_linear_scale) + + expect(field).to be_valid + expect(field.matrix_statements).to be_present + end + end + describe '#logic?' do it 'returns true when there is logic' do field.logic = { 'rules' => [{ if: 2, goto_page_id: 'some_page_id' }] } diff --git a/back/spec/services/export/xlsx/input_sheet_generator_spec.rb b/back/spec/services/export/xlsx/input_sheet_generator_spec.rb index 9e41760bd744..113d88bf36dd 100644 --- a/back/spec/services/export/xlsx/input_sheet_generator_spec.rb +++ b/back/spec/services/export/xlsx/input_sheet_generator_spec.rb @@ -248,6 +248,7 @@ create(:custom_field_option, custom_field: multiselect_field, key: 'dog', title_multiloc: { 'en' => 'Dog' }) end let!(:ranking_field) { create(:custom_field_ranking, :with_options, resource: form) } + let!(:matrix_field) { create(:custom_field_matrix_linear_scale, resource: form) } let(:survey_response1) do create( @@ -264,7 +265,7 @@ project: phase.project, creation_phase: phase, phases: [phase], - custom_field_values: { multiselect_field.key => %w[cat] } + custom_field_values: { multiselect_field.key => %w[cat], matrix_field.key => { 'send_more_animals_to_space' => 3, 'ride_bicycles_more_often' => 4 } } ) end let(:survey_response3) do @@ -294,6 +295,8 @@ 'ID', 'What are your favourite pets?', 'Rank your favourite means of public transport', + 'We should send more animals into space', + 'We should ride our bicycles more often', 'Author ID', 'Submitted at', 'Project' @@ -303,6 +306,8 @@ survey_response1.id, 'Cat, Dog', 'By train, By bike', + nil, + nil, survey_response1.author_id, an_instance_of(DateTime), # created_at phase.project.title_multiloc['en'] @@ -311,6 +316,8 @@ survey_response2.id, 'Cat', '', + 3, + 4, survey_response2.author_id, an_instance_of(DateTime), # created_at phase.project.title_multiloc['en'] @@ -319,6 +326,8 @@ survey_response3.id, 'Dog', 'By bike, By train', + nil, + nil, survey_response3.author_id, an_instance_of(DateTime), # created_at phase.project.title_multiloc['en'] @@ -340,6 +349,8 @@ 'What are your favourite pets?', 'Type your answer', 'Rank your favourite means of public transport', + 'We should send more animals into space', + 'We should ride our bicycles more often', 'Author ID', 'Submitted at', 'Project' @@ -350,6 +361,8 @@ 'Cat, Dog, Other', 'Fish', '', + nil, + nil, survey_response1.author_id, an_instance_of(DateTime), # created_at phase.project.title_multiloc['en'] @@ -359,6 +372,8 @@ 'Cat', '', '', + 3, + 4, survey_response2.author_id, an_instance_of(DateTime), # created_at phase.project.title_multiloc['en'] @@ -368,6 +383,8 @@ 'Dog', '', 'By bike, By train', + nil, + nil, survey_response3.author_id, an_instance_of(DateTime), # created_at phase.project.title_multiloc['en'] @@ -397,6 +414,8 @@ 'ID', 'What are your favourite pets?', 'Rank your favourite means of public transport', + 'We should send more animals into space', + 'We should ride our bicycles more often', 'Author name', 'Author email', 'Author ID', @@ -417,6 +436,8 @@ 'ID', 'What are your favourite pets?', 'Rank your favourite means of public transport', + 'We should send more animals into space', + 'We should ride our bicycles more often', 'Author name', 'Author email', 'Author ID', @@ -428,6 +449,8 @@ survey_response1.id, 'Cat, Dog', 'By train, By bike', + nil, + nil, survey_response1.author_name, survey_response1.author.email, survey_response1.author_id, @@ -438,6 +461,8 @@ survey_response2.id, 'Cat', '', + 3, + 4, survey_response2.author_name, survey_response2.author.email, survey_response2.author_id, @@ -451,6 +476,8 @@ nil, nil, nil, + nil, + nil, an_instance_of(DateTime), # created_at phase.project.title_multiloc['en'] ] diff --git a/back/spec/services/idea_custom_fields_service_spec.rb b/back/spec/services/idea_custom_fields_service_spec.rb index 49c82b1fd314..961c30939280 100644 --- a/back/spec/services/idea_custom_fields_service_spec.rb +++ b/back/spec/services/idea_custom_fields_service_spec.rb @@ -487,6 +487,7 @@ select_option = create(:custom_field_option, custom_field: select_field) page2 = create(:custom_field_page, resource: custom_form) text_field = create(:custom_field_text, resource: custom_form) + matrix_field = create(:custom_field_matrix_linear_scale, resource: custom_form) page3 = create(:custom_field_page, resource: custom_form) multi_select_field = create(:custom_field_multiselect, resource: custom_form) _multi_select_option = create(:custom_field_option, custom_field: multi_select_field) @@ -495,14 +496,14 @@ select_field.update!(logic: { rules: [{ if: select_option.id, goto_page_id: page3.id }] }) page2.update!(logic: { next_page_id: page3.id }) - expect(CustomField.count).to eq 8 + expect(CustomField.count).to eq 9 expect(CustomMaps::MapConfig.count).to eq 1 fields = service.duplicate_all_fields - expect(CustomField.count).to eq 8 + expect(CustomField.count).to eq 9 expect(CustomMaps::MapConfig.count).to eq 2 - expect(fields.count).to eq 8 + expect(fields.count).to eq 9 # page 1 expect(fields[0].id).not_to eq page1.id @@ -511,7 +512,7 @@ expect(fields[1].id).not_to eq select_field.id expect(fields[1].logic).to match({ 'rules' => [ - { 'if' => fields[1].options[0].temp_id, 'goto_page_id' => fields[4].id } + { 'if' => fields[1].options[0].temp_id, 'goto_page_id' => fields[5].id } ] }) expect(fields[1].options[0].temp_id).to match 'TEMP-ID-' @@ -519,26 +520,31 @@ # page 2 expect(fields[2].id).not_to eq page2.id expect(fields[2].logic).to match({ - 'next_page_id' => fields[4].id + 'next_page_id' => fields[5].id }) # text field expect(fields[3].id).not_to eq text_field.id + # matrix field + expect(fields[4].id).not_to eq matrix_field.id + expect(fields[4].matrix_statements[0].id).not_to eq matrix_field.matrix_statements[0].id + expect(fields[4].matrix_statements.map(&:key)).to eq matrix_field.matrix_statements.map(&:key) + # page 3 - expect(fields[4].id).not_to eq page3.id + expect(fields[5].id).not_to eq page3.id # multi select field - expect(fields[5].id).not_to eq multi_select_field.id - expect(fields[5].options[0].temp_id).to match 'TEMP-ID-' + expect(fields[6].id).not_to eq multi_select_field.id + expect(fields[6].options[0].temp_id).to match 'TEMP-ID-' # map field - duplicates map config - expect(fields[6].id).not_to eq map_field.id - expect(fields[6].map_config.id).not_to eq map_field.map_config.id + expect(fields[7].id).not_to eq map_field.id + expect(fields[7].map_config.id).not_to eq map_field.map_config.id # map field 2 - has no map config - expect(fields[7].id).not_to eq map_field_no_config.id - expect(fields[7].map_config).to be_nil + expect(fields[8].id).not_to eq map_field_no_config.id + expect(fields[8].map_config).to be_nil end end end diff --git a/back/spec/services/json_forms_service_spec.rb b/back/spec/services/json_forms_service_spec.rb index 07c0d48153a4..d2f06f606f0b 100644 --- a/back/spec/services/json_forms_service_spec.rb +++ b/back/spec/services/json_forms_service_spec.rb @@ -133,8 +133,9 @@ create(:custom_field, key: 'field5', input_type: 'checkbox'), create(:custom_field, key: 'field6', input_type: 'date'), create(:custom_field_ranking, :with_options, key: 'field7'), - create(:custom_field, key: 'field8', input_type: 'multiline_text', enabled: false, required: true), - create(:custom_field, key: 'field9', input_type: 'text', hidden: true, enabled: true) + create(:custom_field_matrix_linear_scale, key: 'field8'), + create(:custom_field, key: 'field9', input_type: 'multiline_text', enabled: false, required: true), + create(:custom_field, key: 'field10', input_type: 'text', hidden: true, enabled: true) ] create(:custom_field_option, key: 'option1', custom_field: fields[2]) create(:custom_field_option, key: 'option2', custom_field: fields[2]) @@ -211,6 +212,26 @@ description: 'Which councils are you attending in our city?' }, scope: '#/properties/field7' + }, + { + type: 'Control', + scope: '#/properties/field8', + label: 'Please indicate how strong you agree or disagree with the following statements.', + options: { + description: 'Which councils are you attending in our city?', + input_type: 'matrix_linear_scale', + statements: [ + { key: 'send_more_animals_to_space', label: 'We should send more animals into space' }, + { key: 'ride_bicycles_more_often', label: 'We should ride our bicycles more often' } + ], + linear_scale_label1: 'Strongly disagree', + linear_scale_label2: '', + linear_scale_label3: '', + linear_scale_label4: '', + linear_scale_label5: 'Strongly agree', + linear_scale_label6: '', + linear_scale_label7: '' + } } ]) end diff --git a/back/spec/services/json_schema_generator_service_spec.rb b/back/spec/services/json_schema_generator_service_spec.rb index 1e3b842fabe1..620ac6d8c6eb 100644 --- a/back/spec/services/json_schema_generator_service_spec.rb +++ b/back/spec/services/json_schema_generator_service_spec.rb @@ -614,4 +614,30 @@ }) end end + + describe '#visit_matrix_linear_scale' do + let(:field) do + create(:custom_field_matrix_linear_scale, required: true, maximum: 5, key: field_key) + end + + it 'returns the schema for the given field' do + expect(generator.visit_matrix_linear_scale(field)).to eq({ + type: 'object', + minProperties: 2, + maxProperties: 2, + properties: { + send_more_animals_to_space: { + type: 'number', + minimum: 1, + maximum: 5 + }, + ride_bicycles_more_often: { + type: 'number', + minimum: 1, + maximum: 5 + } + } + }) + end + end end diff --git a/back/spec/services/survey_results_generator_service_spec.rb b/back/spec/services/survey_results_generator_service_spec.rb index 927f518ca47c..c2029261a12d 100644 --- a/back/spec/services/survey_results_generator_service_spec.rb +++ b/back/spec/services/survey_results_generator_service_spec.rb @@ -255,6 +255,8 @@ ) end + let_it_be(:matrix_linear_scale_field) { create(:custom_field_matrix_linear_scale, resource: form) } + let_it_be(:gender_user_custom_field) do create(:custom_field_gender, :with_options) end @@ -289,7 +291,11 @@ line_field.key => { type: 'LineString', coordinates: [[1.1, 2.2], [3.3, 4.4]] }, polygon_field.key => { type: 'Polygon', coordinates: [[[1.1, 2.2], [3.3, 4.4], [5.5, 6.6], [1.1, 2.2]]] }, linear_scale_field.key => 3, - number_field.key => 42 + number_field.key => 42, + matrix_linear_scale_field.key => { + 'send_more_animals_to_space' => 1, + 'ride_bicycles_more_often' => 3 + } }, idea_files: [idea_file1, idea_file2], author: female_user @@ -306,7 +312,10 @@ point_field.key => { type: 'Point', coordinates: [11.22, 33.44] }, line_field.key => { type: 'LineString', coordinates: [[1.2, 2.3], [3.4, 4.5]] }, polygon_field.key => { type: 'Polygon', coordinates: [[[1.2, 2.3], [3.4, 4.5], [5.6, 6.7], [1.2, 2.3]]] }, - linear_scale_field.key => 4 + linear_scale_field.key => 4, + matrix_linear_scale_field.key => { + 'send_more_animals_to_space' => 1 + } }, author: male_user ) @@ -319,7 +328,10 @@ multiselect_field.key => %w[cat dog], select_field.key => 'other', "#{select_field.key}_other" => 'Austin', - multiselect_image_field.key => ['house'] + multiselect_image_field.key => ['house'], + matrix_linear_scale_field.key => { + 'ride_bicycles_more_often' => 3 + } }, author: female_user ) @@ -333,7 +345,11 @@ select_field.key => 'other', ranking_field.key => %w[by_bike by_foot by_train], "#{select_field.key}_other" => 'Miami', - multiselect_image_field.key => ['house'] + multiselect_image_field.key => ['house'], + matrix_linear_scale_field.key => { + 'send_more_animals_to_space' => 3, + 'ride_bicycles_more_often' => 4 + } }, author: male_user ) @@ -354,7 +370,11 @@ phases: phases_of_inputs, custom_field_values: { select_field.key => 'other', - "#{select_field.key}_other" => 'Seattle' + "#{select_field.key}_other" => 'Seattle', + matrix_linear_scale_field.key => { + 'send_more_animals_to_space' => 4, + 'ride_bicycles_more_often' => 4 + } }, author: male_user ) @@ -392,7 +412,7 @@ end it 'returns the correct fields and structure' do - expect(generated_results[:results].count).to eq 14 + expect(generated_results[:results].count).to eq 15 expect(generated_results[:results].pluck(:customFieldId)).not_to include page_field.id expect(generated_results[:results].pluck(:customFieldId)).not_to include disabled_multiselect_field.id end @@ -782,6 +802,69 @@ end end + describe 'matrix_linear_scale field' do + let(:expected_result_matrix_linear_scale) do + { + customFieldId: matrix_linear_scale_field.id, + inputType: 'matrix_linear_scale', + question: { + 'en' => 'Please indicate how strong you agree or disagree with the following statements.' + }, + required: false, + grouped: false, + totalResponseCount: 27, + questionResponseCount: 5, + multilocs: { + answer: { + 1 => { title_multiloc: { 'en' => '1 - Strongly disagree', 'fr-FR' => '1', 'nl-NL' => '1' } }, + 2 => { title_multiloc: { 'en' => '2', 'fr-FR' => '2', 'nl-NL' => '2' } }, + 3 => { title_multiloc: { 'en' => '3', 'fr-FR' => '3', 'nl-NL' => '3' } }, + 4 => { title_multiloc: { 'en' => '4', 'fr-FR' => '4', 'nl-NL' => '4' } }, + 5 => { title_multiloc: { 'en' => '5 - Strongly agree', 'fr-FR' => '5', 'nl-NL' => '5' } } + } + }, + linear_scales: { + 'send_more_animals_to_space' => { + question: { + 'en' => 'We should send more animals into space' + }, + questionResponseCount: 4, + answers: [ + { answer: 5, count: 0, percentage: 0.0 }, + { answer: 4, count: 1, percentage: 0.25 }, + { answer: 3, count: 1, percentage: 0.25 }, + { answer: 2, count: 0, percentage: 0.0 }, + { answer: 1, count: 2, percentage: 0.5 }, + { answer: nil, count: 23 } + ] + }, + 'ride_bicycles_more_often' => { + question: { + 'en' => 'We should ride our bicycles more often' + }, + questionResponseCount: 4, + answers: [ + { answer: 5, count: 0, percentage: 0.0 }, + { answer: 4, count: 2, percentage: 0.5 }, + { answer: 3, count: 2, percentage: 0.5 }, + { answer: 2, count: 0, percentage: 0.0 }, + { answer: 1, count: 0, percentage: 0.0 }, + { answer: nil, count: 23 } + ] + } + } + } + end + + it 'returns the results for a matrix linear scale field' do + expect(generated_results[:results][14]).to match expected_result_matrix_linear_scale + end + + it 'returns a single result for a linear scale field' do + expect(generator.generate_results(field_id: matrix_linear_scale_field.id)).to match expected_result_matrix_linear_scale + end + end + describe 'select field' do let(:expected_result_select) do { diff --git a/back/spec/services/ui_schema_generator_service_spec.rb b/back/spec/services/ui_schema_generator_service_spec.rb index 40e66a229399..f4dc0cbc19f8 100644 --- a/back/spec/services/ui_schema_generator_service_spec.rb +++ b/back/spec/services/ui_schema_generator_service_spec.rb @@ -655,6 +655,33 @@ def generate_for_current_locale(fields) end end + describe '#visit_matrix_linear_scale' do + let(:field) { create(:custom_field_matrix_linear_scale, key: field_key) } + + it 'returns the schema for the given field' do + expect(generator.visit_matrix_linear_scale(field)).to eq({ + type: 'Control', + scope: "#/properties/#{field_key}", + label: 'Please indicate how strong you agree or disagree with the following statements.', + options: { + description: 'Which councils are you attending in our city?', + input_type: 'matrix_linear_scale', + statements: [ + { key: 'send_more_animals_to_space', label: 'We should send more animals into space' }, + { key: 'ride_bicycles_more_often', label: 'We should ride our bicycles more often' } + ], + linear_scale_label1: 'Strongly disagree', + linear_scale_label2: '', + linear_scale_label3: '', + linear_scale_label4: '', + linear_scale_label5: 'Strongly agree', + linear_scale_label6: '', + linear_scale_label7: '' + } + }) + end + end + describe '#visit_page' do let(:field) { create(:custom_field_page) }