diff --git a/app/assets/stylesheets/admin/budgets/links.scss b/app/assets/stylesheets/admin/budgets/links.scss index f712aee3246..0188990631c 100644 --- a/app/assets/stylesheets/admin/budgets/links.scss +++ b/app/assets/stylesheets/admin/budgets/links.scss @@ -33,4 +33,8 @@ .results-link { @include has-fa-icon(poll, solid); } + + .sensemaker-analyses-link { + @include has-fa-icon(microscope, solid); + } } diff --git a/app/assets/stylesheets/sensemaker/sensemaker.scss b/app/assets/stylesheets/sensemaker/sensemaker.scss index 3335ab2bca9..a1fcbf8d6ef 100644 --- a/app/assets/stylesheets/sensemaker/sensemaker.scss +++ b/app/assets/stylesheets/sensemaker/sensemaker.scss @@ -1,3 +1,33 @@ +@mixin flex-row { + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: flex-start; +} + +@mixin flex-between { + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: space-between; +} + +.sensemaker-analyses-link { + @include has-fa-icon(microscope, solid); + + &::before { + left: -0.25rem; + position: relative; + top: -0.1rem; + } +} + +.sensemaker-icon { + @include has-fa-icon(microscope, solid); +} + .admin .sensemaker { .additional-context { border: 1px solid $border; @@ -178,8 +208,6 @@ list-style: none; } - // (moved to shallower block below) - li.result-group { list-style: none; margin-bottom: 0.5rem; @@ -187,8 +215,6 @@ padding: 0; } - // (moved to shallower block below) - li.result-row { opacity: 0.8; padding: 0.1rem; @@ -284,7 +310,6 @@ background-color: #f8f9fa; } - // Job links styling .job-link { color: $brand; font-weight: bold; @@ -295,7 +320,6 @@ } } - // Job details styling .job-details-table { border-collapse: collapse; width: 100%; @@ -335,11 +359,25 @@ background-color: $color-info; } } + + .filter-controls { + @include flex-between; + margin-bottom: $line-height; + + select { + height: $line-height * 1.5; + margin-bottom: 0; + } + label { font-weight: normal; opacity: 0.8; } + + a, + p { margin-bottom: 0; } + p { opacity: 0.8; } + } } .sensemaker { - // Responsive adjustments @include breakpoint(small only) { .hero-section { h1 { @@ -363,22 +401,13 @@ } .flex-between { - align-items: center; - display: flex; - flex-direction: row; - gap: 2px; - justify-content: space-between; + @include flex-between; + button, .button { margin-bottom: 0; } } - - .flex-row { - align-items: center; - display: flex; - flex-direction: row; - gap: 2px; - justify-content: flex-start; - } + .flex-row { @include flex-row; } + .fit-content { width: fit-content; } .flex-grow { flex: 1; @@ -388,7 +417,7 @@ align-items: center; display: flex; flex-direction: row; - gap: 2px; + gap: 4px; justify-content: flex-end; } @@ -407,7 +436,6 @@ text-transform: uppercase; } - // Target header (quiz-header style box) .target-header { margin-bottom: 2.2rem; diff --git a/app/components/admin/budgets/show_component.html.erb b/app/components/admin/budgets/show_component.html.erb index 5f1142b5dbf..2b84c88f30c 100644 --- a/app/components/admin/budgets/show_component.html.erb +++ b/app/components/admin/budgets/show_component.html.erb @@ -22,3 +22,6 @@

<%= t("admin.budgets.edit.actions") %>

<%= render Admin::Budgets::ActionsComponent.new(budget) %> + +
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(budget) %> diff --git a/app/components/admin/sensemaker/analyses_link_component.html.erb b/app/components/admin/sensemaker/analyses_link_component.html.erb new file mode 100644 index 00000000000..ae9bb990ee0 --- /dev/null +++ b/app/components/admin/sensemaker/analyses_link_component.html.erb @@ -0,0 +1 @@ +<%= link_to link_text, link_path, class: "sensemaker-analyses-link" %> diff --git a/app/components/admin/sensemaker/analyses_link_component.rb b/app/components/admin/sensemaker/analyses_link_component.rb new file mode 100644 index 00000000000..e394fe31156 --- /dev/null +++ b/app/components/admin/sensemaker/analyses_link_component.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Admin::Sensemaker::AnalysesLinkComponent < ApplicationComponent + attr_reader :record + + def initialize(record) + @record = record + end + + def render? + feature?(:sensemaker) && jobs_count.positive? + end + + def jobs_count + @jobs_count ||= Sensemaker::Job.for_analysable(record, published_only: false).count + end + + def link_text + t("admin.sensemaker.index.sensemaker_analyses_count", count: jobs_count) + end + + def link_path + admin_sensemaker_jobs_path(resource_type: admin_resource_type, resource_id: record.id) + end + + private + + def admin_resource_type + case record + when Budget + "budgets" + when Debate + "debates" + when Proposal + "proposals" + when Poll + "polls" + when Legislation::Process + "legislation_processes" + else + raise ArgumentError, "Unsupported record type for admin sensemaker link: #{record.class}" + end + end +end diff --git a/app/components/admin/sensemaker/index_component.html.erb b/app/components/admin/sensemaker/index_component.html.erb index b9344fa8538..fe99ec1bc24 100644 --- a/app/components/admin/sensemaker/index_component.html.erb +++ b/app/components/admin/sensemaker/index_component.html.erb @@ -48,9 +48,31 @@

<%= t("admin.sensemaker.index.past_runs") %> - <%= link_to t("admin.sensemaker.index.new_run"), new_admin_sensemaker_job_path, class: "button" %> + <%= link_to t("admin.sensemaker.index.new_run"), new_admin_sensemaker_job_path, class: "button success" %>

+
+ <% if target_specified? %> +

<%= t("admin.sensemaker.index.filtered_by_target", name: filter_description) %>

+ <% else %> + <%= form_tag admin_sensemaker_jobs_path, method: :get, enforce_utf8: false, class: "js-submit-on-change" do %> +
+ <%= label_tag :resource_type, t("admin.sensemaker.index.filter_by"), class: "" %> + <%= select_tag :resource_type, + options_for_select( + filter_resource_type_options.map { |type| [t("admin.sensemaker.index.resource_types.#{type}"), type] }, + filter_resource_type + ), + { prompt: t("admin.sensemaker.index.filter_all_types"), class: "fit-content" } %> +
+ <% end %> + <% end %> + + <% if filter_active? %> + <%= link_to t("admin.sensemaker.index.filter_reset"), admin_sensemaker_jobs_path, class: "" %> + <% end %> +
+ diff --git a/app/components/admin/sensemaker/index_component.rb b/app/components/admin/sensemaker/index_component.rb index 79534dce4f8..2a217dac5a8 100644 --- a/app/components/admin/sensemaker/index_component.rb +++ b/app/components/admin/sensemaker/index_component.rb @@ -1,11 +1,17 @@ class Admin::Sensemaker::IndexComponent < ApplicationComponent include Header - attr_reader :sensemaker_jobs, :running_jobs + attr_reader :sensemaker_jobs, :running_jobs, :filter_target, + :filter_resource_type, :filter_resource_id, :filter_resource_type_options - def initialize(sensemaker_jobs, running_jobs) + def initialize(sensemaker_jobs, running_jobs, filter_target: nil, + filter_resource_type: nil, filter_resource_id: nil, filter_resource_type_options: []) @sensemaker_jobs = sensemaker_jobs @running_jobs = running_jobs + @filter_target = filter_target + @filter_resource_type = filter_resource_type + @filter_resource_id = filter_resource_id + @filter_resource_type_options = filter_resource_type_options || [] end def title @@ -15,4 +21,33 @@ def title def enabled? feature?(:sensemaker) end + + def target_specified? + filter_target.present? + end + + def filter_active? + filter_target.present? || filter_resource_type.present? + end + + def filter_description + return filter_target_name if filter_target.present? + return t("admin.sensemaker.index.resource_types.#{filter_resource_type}") if filter_resource_type.present? + + nil + end + + def filter_target_name + return nil unless filter_target + + if filter_target.respond_to?(:title) && filter_target.title.present? + filter_target.title + elsif filter_target.respond_to?(:name) && filter_target.name.present? + filter_target.name + elsif filter_target.respond_to?(:value) && filter_target.value.present? + filter_target.value + else + "##{filter_target.id}" + end + end end diff --git a/app/components/admin/sensemaker/job_show_component.html.erb b/app/components/admin/sensemaker/job_show_component.html.erb index c8345914ba0..38bafd3849c 100644 --- a/app/components/admin/sensemaker/job_show_component.html.erb +++ b/app/components/admin/sensemaker/job_show_component.html.erb @@ -71,7 +71,6 @@ <% end %> - diff --git a/app/controllers/admin/sensemaker/jobs_controller.rb b/app/controllers/admin/sensemaker/jobs_controller.rb index fe834e4be26..07e71ea75f1 100644 --- a/app/controllers/admin/sensemaker/jobs_controller.rb +++ b/app/controllers/admin/sensemaker/jobs_controller.rb @@ -1,10 +1,34 @@ class Admin::Sensemaker::JobsController < Admin::BaseController + include Sensemaker::ResourceTypeResolution + def index @running_jobs = Sensemaker::Job.running.includes(:children).order(created_at: :desc) - @sensemaker_jobs = Sensemaker::Job.where(parent_job_id: nil) - .includes(:children) - .where.not(id: @running_jobs.pluck(:id)) - .order(created_at: :desc) + + @filter_resource_type, @filter_resource_id = normalized_filter_params + if @filter_resource_type.present? && @filter_resource_id.present? + @filter_target = sensemaker_find_resource(@filter_resource_type, @filter_resource_id) + unless @filter_target + redirect_to admin_sensemaker_jobs_path, alert: t("admin.sensemaker.index.target_not_found") + return + end + base_scope = Sensemaker::Job.for_analysable(@filter_target, published_only: false) + elsif @filter_resource_type.present? + resource_class = sensemaker_model_for_resource_type(@filter_resource_type) + unless resource_class + redirect_to admin_sensemaker_jobs_path, alert: t("admin.sensemaker.index.target_not_found") + return + end + base_scope = Sensemaker::Job.by_analysable_type(resource_class.to_s) + else + base_scope = Sensemaker::Job + end + + @sensemaker_jobs = base_scope.where(parent_job_id: nil) + .includes(:children) + .where.not(id: @running_jobs.pluck(:id)) + .order(created_at: :desc) + + @sensemaker_resource_types = Sensemaker::ResourceTypeResolution::SENSEMAKER_RESOURCE_TYPES end def show @@ -281,6 +305,10 @@ def help private + def normalized_filter_params + [params[:resource_type].presence, params[:resource_id].presence] + end + def sensemaker_job_params params.require(:sensemaker_job).permit(:analysable_type, :analysable_id, :script, :additional_context) end diff --git a/app/controllers/concerns/sensemaker/resource_type_resolution.rb b/app/controllers/concerns/sensemaker/resource_type_resolution.rb new file mode 100644 index 00000000000..5bf838b9299 --- /dev/null +++ b/app/controllers/concerns/sensemaker/resource_type_resolution.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Sensemaker + module ResourceTypeResolution + extend ActiveSupport::Concern + + SENSEMAKER_RESOURCE_TYPES = %w[ + budgets + debates + proposals + polls + poll_questions + legislation_processes + legislation_questions + legislation_proposals + legislation_question_options + ].freeze + + def sensemaker_model_for_resource_type(resource_type) + case resource_type.to_s + when "budgets" + Budget + when "debates" + Debate + when "proposals" + Proposal + when "polls" + Poll + when "poll_questions" + Poll::Question + when "legislation_processes" + Legislation::Process + when "legislation_questions" + Legislation::Question + when "legislation_proposals" + Legislation::Proposal + when "legislation_question_options" + Legislation::QuestionOption + else + nil + end + end + + def sensemaker_find_resource(resource_type, resource_id) + model = sensemaker_model_for_resource_type(resource_type) + return nil unless model + + if model == Budget + model.find_by_slug_or_id(resource_id) + else + model.find(resource_id) + end + rescue ActiveRecord::RecordNotFound + nil + end + end +end diff --git a/app/controllers/sensemaker/jobs_controller.rb b/app/controllers/sensemaker/jobs_controller.rb index aa1be673159..72d53996679 100644 --- a/app/controllers/sensemaker/jobs_controller.rb +++ b/app/controllers/sensemaker/jobs_controller.rb @@ -1,4 +1,6 @@ class Sensemaker::JobsController < ApplicationController + include Sensemaker::ResourceTypeResolution + skip_authorization_check def show @@ -14,29 +16,26 @@ def show end def index - if params[:resource_type].blank? || params[:resource_id].blank? - head :not_found - return - end + resource_type, resource_id = [params[:resource_type].presence, params[:resource_id].presence] + raise ArgumentError, "Unknown resource type: #{resource_type}" if resource_type.blank? - resource_type = map_resource_type_to_model(params[:resource_type]) - @resource = resource_type.find(params[:resource_id]) - @parent_resource = load_parent_resource_for(@resource) + @resource = sensemaker_find_resource(resource_type, resource_id) + raise ActiveRecord::RecordNotFound, "Resource not found" unless @resource + @parent_resource = load_parent_resource_for(@resource) @sensemaker_jobs = case @resource when Poll Sensemaker::Job.for_poll(@resource).order(finished_at: :desc) when Legislation::Question Sensemaker::Job.for_legislation_question(@resource).order(finished_at: :desc) when Legislation::Process - Sensemaker::Job.published.for_process(@resource).order(finished_at: :desc) + Sensemaker::Job.for_process(@resource).order(finished_at: :desc) else Sensemaker::Job.published - .where(analysable_type: resource_type.name, - analysable_id: params[:resource_id]) + .where(analysable: @resource) .order(finished_at: :desc) end - rescue ActiveRecord::RecordNotFound + rescue ActiveRecord::RecordNotFound, ArgumentError head :not_found end @@ -69,31 +68,6 @@ def serve_report private - def map_resource_type_to_model(resource_type) - case resource_type - when "debates" - Debate - when "proposals" - Proposal - when "polls" - Poll - when "topics" - Topic - when "poll_questions" - Poll::Question - when "legislation_processes" - Legislation::Process - when "legislation_questions" - Legislation::Question - when "legislation_proposals" - Legislation::Proposal - when "legislation_question_options" - Legislation::QuestionOption - else - raise ArgumentError, "Unknown resource type: #{resource_type}" - end - end - def load_parent_resource_for(resource) case resource when Poll::Question diff --git a/app/models/sensemaker/job.rb b/app/models/sensemaker/job.rb index 2040356ce69..94b9ddf47bf 100644 --- a/app/models/sensemaker/job.rb +++ b/app/models/sensemaker/job.rb @@ -209,41 +209,120 @@ def publishable? PUBLISHABLE_SCRIPTS.include?(script) && finished? && !errored? && has_outputs? end - def self.for_budget(budget) + def self.budget_related + where(analysable_type: "Budget").or( + where(analysable_type: "Budget::Group") + ) + end + + def self.for_budget_any_status(budget) group_subquery = budget.groups.select(:id) - published.where(analysable_type: "Budget", analysable_id: budget.id).or( - published.where(analysable_type: "Budget::Group", analysable_id: group_subquery) + where(analysable_type: "Budget", analysable_id: budget.id).or( + where(analysable_type: "Budget::Group", analysable_id: group_subquery) ) end - def self.for_process(process) + def self.for_budget(budget) + published.merge(for_budget_any_status(budget)) + end + + def self.process_related + where(analysable_type: "Legislation::Process").or( + where(analysable_type: "Legislation::Proposal").or( + where(analysable_type: "Legislation::Question").or( + where(analysable_type: "Legislation::QuestionOption") + ) + ) + ) + end + + def self.for_process_any_status(process) proposals_subquery = process.proposals.select(:id) questions_subquery = process.questions.select(:id) question_options_subquery = Legislation::QuestionOption .where(legislation_question_id: questions_subquery) .select(:id) - published - .where(analysable_type: "Legislation::Proposal", analysable_id: proposals_subquery) - .or(published.where(analysable_type: "Legislation::Question", analysable_id: questions_subquery)) - .or(published.where(analysable_type: "Legislation::QuestionOption", - analysable_id: question_options_subquery)) + where(analysable_type: "Legislation::Proposal", analysable_id: proposals_subquery) + .or(where(analysable_type: "Legislation::Question", analysable_id: questions_subquery)) + .or(where(analysable_type: "Legislation::QuestionOption", + analysable_id: question_options_subquery)) end - def self.for_poll(poll) + def self.for_process(process) + published.merge(for_process_any_status(process)) + end + + def self.poll_related + where(analysable_type: "Poll").or( + where(analysable_type: "Poll::Question") + ) + end + + def self.for_poll_any_status(poll) questions_subquery = poll.questions.select(:id) - published.where(analysable_type: "Poll", analysable_id: poll.id).or( - published.where(analysable_type: "Poll::Question", analysable_id: questions_subquery) + where(analysable_type: "Poll", analysable_id: poll.id).or( + where(analysable_type: "Poll::Question", analysable_id: questions_subquery) ) end - def self.for_legislation_question(question) + def self.for_poll(poll) + published.merge(for_poll_any_status(poll)) + end + + def self.legislation_question_related + where(analysable_type: "Legislation::Question").or( + where(analysable_type: "Legislation::QuestionOption") + ) + end + + def self.for_legislation_question_any_status(question) options_subquery = question.question_options.select(:id) - published.where(analysable_type: "Legislation::Question", analysable_id: question.id).or( - published.where(analysable_type: "Legislation::QuestionOption", analysable_id: options_subquery) + where(analysable_type: "Legislation::Question", analysable_id: question.id).or( + where(analysable_type: "Legislation::QuestionOption", analysable_id: options_subquery) ) end + def self.for_legislation_question(question) + published.merge(for_legislation_question_any_status(question)) + end + + def self.for_analysable(record, published_only: true) + if record == Proposal + base = where(analysable_type: "Proposal", analysable_id: nil) + return published_only ? base.merge(published) : base + end + + case record + when Budget + published_only ? for_budget(record) : for_budget_any_status(record) + when Legislation::Process + published_only ? for_process(record) : for_process_any_status(record) + when Poll + published_only ? for_poll(record) : for_poll_any_status(record) + when Legislation::Question + published_only ? for_legislation_question(record) : for_legislation_question_any_status(record) + else + base = where(analysable: record) + published_only ? base.merge(published) : base + end + end + + def self.by_analysable_type(type) + case type + when "Budget" + budget_related + when "Legislation::Process" + process_related + when "Poll" + poll_related + when "Legislation::Question" + legislation_question_related + else + where(analysable_type: type) + end + end + private def publishing_is_allowed diff --git a/app/views/admin/debates/show.html.erb b/app/views/admin/debates/show.html.erb index b544094257f..81a71bb9d9c 100644 --- a/app/views/admin/debates/show.html.erb +++ b/app/views/admin/debates/show.html.erb @@ -19,3 +19,6 @@ <%= render "shared/tags", taggable: @debate %> + +
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(@debate) %> diff --git a/app/views/admin/legislation/processes/edit.html.erb b/app/views/admin/legislation/processes/edit.html.erb index 2d2f148f086..927f0cb20e6 100644 --- a/app/views/admin/legislation/processes/edit.html.erb +++ b/app/views/admin/legislation/processes/edit.html.erb @@ -11,3 +11,6 @@ <%= render Admin::Legislation::Processes::FormComponent.new(@process) %> + +
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(@process) %> diff --git a/app/views/admin/poll/polls/show.html.erb b/app/views/admin/poll/polls/show.html.erb index be45eeb319a..ef98c480470 100644 --- a/app/views/admin/poll/polls/show.html.erb +++ b/app/views/admin/poll/polls/show.html.erb @@ -5,3 +5,6 @@ <%= render "questions" %> + +
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(@poll) %> diff --git a/app/views/admin/proposals/show.html.erb b/app/views/admin/proposals/show.html.erb index afb2379e36e..a98d562d6e5 100644 --- a/app/views/admin/proposals/show.html.erb +++ b/app/views/admin/proposals/show.html.erb @@ -33,3 +33,6 @@ <%= render "admin/milestones/milestones", milestoneable: @proposal %> + +
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(@proposal) %> diff --git a/app/views/admin/sensemaker/jobs/index.html.erb b/app/views/admin/sensemaker/jobs/index.html.erb index a3701fef857..eb2452b4019 100644 --- a/app/views/admin/sensemaker/jobs/index.html.erb +++ b/app/views/admin/sensemaker/jobs/index.html.erb @@ -1 +1,8 @@ -<%= render Admin::Sensemaker::IndexComponent.new(@sensemaker_jobs, @running_jobs) %> +<%= render Admin::Sensemaker::IndexComponent.new( + @sensemaker_jobs, + @running_jobs, + filter_target: @filter_target, + filter_resource_type: @filter_resource_type, + filter_resource_id: @filter_resource_id, + filter_resource_type_options: @sensemaker_resource_types +) %> diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml index 4debe1eddf1..2b899d813b5 100644 --- a/config/locales/en/admin.yml +++ b/config/locales/en/admin.yml @@ -1779,6 +1779,24 @@ en: table_actions: "Actions" cancel_all: "Cancel all" new_run: "New Run" + target_not_found: "Target not found." + filtered_by_target: "Showing jobs for %{name}" + filter_by: "Filter by" + filter_all_types: "-" + filter_reset: "× Clear filters" + sensemaker_analyses_count: + one: "1 sensemaker analysis" + other: "%{count} sensemaker analyses" + resource_types: + budgets: "Budgets" + debates: "Debates" + proposals: "Proposals" + polls: "Polls" + poll_questions: "Poll questions" + legislation_processes: "Legislation processes" + legislation_questions: "Legislation questions" + legislation_proposals: "Legislation proposals" + legislation_question_options: "Legislation question options" new: title: New Sensemaker analysis select_target: "Select a target from the results" diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml index c0e4fd70296..a785b9fe336 100644 --- a/config/locales/es/admin.yml +++ b/config/locales/es/admin.yml @@ -1827,6 +1827,24 @@ es: table_actions: "Acciones" cancel_all: "Cancelar todo" new_run: "Nueva ejecución" + target_not_found: "Objetivo no encontrado." + filtered_by_target: "Mostrando trabajos de %{name}" + filter_by: "Filtrar por" + filter_all_types: "-" + filter_reset: "× Limpiar filtros" + sensemaker_analyses_count: + one: "1 análisis Sensemaker" + other: "%{count} análisis Sensemaker" + resource_types: + budgets: "Presupuestos" + debates: "Debates" + proposals: "Propuestas" + polls: "Encuestas" + poll_questions: "Preguntas de encuesta" + legislation_processes: "Procesos legislativos" + legislation_questions: "Preguntas legislativas" + legislation_proposals: "Propuestas legislativas" + legislation_question_options: "Opciones de pregunta legislativa" new: title: Nuevo análisis Sensemaker select_target: "Selecciona un objetivo de los resultados" diff --git a/spec/components/admin/sensemaker/analyses_link_component_spec.rb b/spec/components/admin/sensemaker/analyses_link_component_spec.rb new file mode 100644 index 00000000000..ef600815400 --- /dev/null +++ b/spec/components/admin/sensemaker/analyses_link_component_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Admin::Sensemaker::AnalysesLinkComponent do + include Rails.application.routes.url_helpers + + let(:debate) { create(:debate) } + let(:component) { Admin::Sensemaker::AnalysesLinkComponent.new(debate) } + + before do + Setting["feature.sensemaker"] = true + end + + describe "#render?" do + context "when sensemaker feature is enabled and at least one job exists (any status)" do + before do + create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id, published: false) + end + + it "returns true" do + expect(component.render?).to be true + end + end + + context "when sensemaker feature is enabled but no jobs exist" do + it "returns false" do + expect(component.render?).to be false + end + end + + context "when sensemaker feature is disabled" do + before do + Setting["feature.sensemaker"] = nil + create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id) + end + + it "returns false" do + expect(component.render?).to be_falsy + end + end + end + + describe "rendering" do + context "when jobs are available" do + before do + create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id) + end + + it "renders a link to the admin sensemaker jobs index filtered by the resource" do + render_inline component + + expect(page).to have_link(I18n.t("admin.sensemaker.index.sensemaker_analyses_count", count: 1), + href: admin_sensemaker_jobs_path(resource_type: "debates", + resource_id: debate.id)) + end + end + + context "when no jobs exist" do + it "does not render the link" do + render_inline component + + expect(page).not_to have_link(I18n.t("admin.sensemaker.index.sensemaker_analyses_count", count: 1)) + end + end + end +end diff --git a/spec/controllers/admin/sensemaker/jobs_controller_spec.rb b/spec/controllers/admin/sensemaker/jobs_controller_spec.rb index 34b4937fa64..aa3d905e1bc 100644 --- a/spec/controllers/admin/sensemaker/jobs_controller_spec.rb +++ b/spec/controllers/admin/sensemaker/jobs_controller_spec.rb @@ -12,10 +12,60 @@ before { sign_in(admin) } describe "GET #index" do - it "returns successful response" do + it "returns successful response and sets no filter_target when no filter params" do get :index expect(response).to have_http_status(:ok) + expect(controller.instance_variable_get(:@filter_target)).to be(nil) + end + + context "when filtering by resource_type and resource_id" do + let!(:debate_job) do + create(:sensemaker_job, + user: admin, + analysable_type: "Debate", + analysable_id: debate.id, + parent_job_id: nil, + started_at: nil, + finished_at: 1.day.ago) + end + let!(:other_job) do + create(:sensemaker_job, + user: admin, + analysable_type: "Debate", + analysable_id: create(:debate).id, + parent_job_id: nil, + started_at: nil, + finished_at: 1.day.ago) + end + + it "sets filter_target and scopes jobs to that resource" do + get :index, params: { resource_type: "debates", resource_id: debate.id } + + expect(response).to have_http_status(:ok) + expect(controller.instance_variable_get(:@filter_target)).to eq(debate) + jobs = controller.instance_variable_get(:@sensemaker_jobs) + expect(jobs).to include(debate_job) + expect(jobs).not_to include(other_job) + end + end + + context "when target is not found" do + it "redirects to index with alert" do + get :index, params: { resource_type: "debates", resource_id: 99999 } + + expect(response).to redirect_to(admin_sensemaker_jobs_path) + expect(flash[:alert]).to be_present + end + end + + context "when resource_type is unknown" do + it "redirects to index with alert" do + get :index, params: { resource_type: "unknown_type", resource_id: 1 } + + expect(response).to redirect_to(admin_sensemaker_jobs_path) + expect(flash[:alert]).to be_present + end end end diff --git a/spec/controllers/sensemaker/jobs_controller_spec.rb b/spec/controllers/sensemaker/jobs_controller_spec.rb index 8040d81ffcc..ef7550c910d 100644 --- a/spec/controllers/sensemaker/jobs_controller_spec.rb +++ b/spec/controllers/sensemaker/jobs_controller_spec.rb @@ -201,6 +201,7 @@ def create_publishable_job_with_output(attributes = {}) it "returns 404" do get :index, params: { resource_type: "debates", resource_id: 99999 } + expect(Debate.find_by(id: 99999)).to be(nil) expect(response).to have_http_status(:not_found) end end diff --git a/spec/models/sensemaker/job_spec.rb b/spec/models/sensemaker/job_spec.rb index 64cc7dd44a9..7efca0d6958 100644 --- a/spec/models/sensemaker/job_spec.rb +++ b/spec/models/sensemaker/job_spec.rb @@ -109,6 +109,117 @@ end end + describe ".for_analysable" do + context "when record is a Budget" do + let(:budget) { create(:budget) } + let!(:published_budget_job) do + j = create(:sensemaker_job, analysable_type: "Budget", analysable_id: budget.id, published: false) + j.update_column(:published, true) + j + end + let!(:unpublished_budget_job) do + create(:sensemaker_job, analysable_type: "Budget", analysable_id: budget.id, published: false) + end + let!(:other_budget_job) do + j = create(:sensemaker_job, + analysable_type: "Budget", + analysable_id: create(:budget).id, + published: false) + j.update_column(:published, true) + j + end + + it "with published_only: true returns only published jobs for that budget" do + scope = Sensemaker::Job.for_analysable(budget, published_only: true) + expect(scope).to include(published_budget_job) + expect(scope).not_to include(unpublished_budget_job) + expect(scope).not_to include(other_budget_job) + end + + it "with published_only: false returns all jobs for that budget" do + scope = Sensemaker::Job.for_analysable(budget, published_only: false) + expect(scope).to include(published_budget_job) + expect(scope).to include(unpublished_budget_job) + expect(scope).not_to include(other_budget_job) + end + end + + context "when record is a Debate (else branch)" do + let(:debate) { create(:debate) } + let!(:published_debate_job) do + j = create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id, published: false) + j.update_column(:published, true) + j + end + let!(:unpublished_debate_job) do + create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id, published: false) + end + + it "with published_only: true returns only published jobs for that record" do + scope = Sensemaker::Job.for_analysable(debate, published_only: true) + expect(scope).to include(published_debate_job) + expect(scope).not_to include(unpublished_debate_job) + end + + it "with published_only: false returns all jobs for that record" do + scope = Sensemaker::Job.for_analysable(debate, published_only: false) + expect(scope).to include(published_debate_job) + expect(scope).to include(unpublished_debate_job) + end + end + + context "when record is Proposal (all proposals)" do + let!(:all_proposals_job) do + j = create(:sensemaker_job, analysable_type: "Proposal", analysable_id: nil, published: false) + j.update_column(:published, true) + j + end + let!(:specific_proposal_job) do + j = create(:sensemaker_job, + analysable_type: "Proposal", + analysable_id: create(:proposal).id, + published: false) + j.update_column(:published, true) + j + end + + it "with published_only: true returns only published jobs with nil analysable_id" do + scope = Sensemaker::Job.for_analysable(Proposal, published_only: true) + expect(scope).to include(all_proposals_job) + expect(scope).not_to include(specific_proposal_job) + end + + it "with published_only: false returns all jobs with nil analysable_id" do + unpublished = create(:sensemaker_job, + analysable_type: "Proposal", + analysable_id: nil, + published: false) + scope = Sensemaker::Job.for_analysable(Proposal, published_only: false) + expect(scope).to include(all_proposals_job) + expect(scope).to include(unpublished) + expect(scope).not_to include(specific_proposal_job) + end + end + + context "when record is a Poll" do + let(:poll) { create(:poll) } + let!(:published_poll_job) do + j = create(:sensemaker_job, analysable_type: "Poll", analysable_id: poll.id, published: false) + j.update_column(:published, true) + j + end + let!(:unpublished_poll_job) do + create(:sensemaker_job, analysable_type: "Poll", analysable_id: poll.id, published: false) + end + + it "with published_only: false returns all jobs for that poll" do + scope = Sensemaker::Job.for_analysable(poll, published_only: false) + expect(scope).to include(published_poll_job) + expect(scope).to include(unpublished_poll_job) + end + end + end + describe "instance methods" do describe "#has_multiple_outputs?" do it "returns true for advanced_runner.ts and runner.ts" do
<%= t("admin.sensemaker.job_show.target_type") %> <%= sensemaker_job.analysable_type %>