From b48244f46256f8d6981c7f402b5186b0084e51b2 Mon Sep 17 00:00:00 2001 From: John Pinto Date: Fri, 24 Oct 2025 15:25:57 +0100 Subject: [PATCH 1/5] Redesigned plan-creation page to enforce template access rules and simplify UI [#3534](https://github.com/DMPRoadmap/roadmap/issues/3534) Co-authored-by: don-stuckey dstuckey@ed.ac.uk This feature was contributed to DMPonline by @don-stuckey. Changes: - Complete re-write of 'Create a new plan' view app/views/plans/new.html.erb. - The Plans controller app/controllers/plans_controller.rb has be greatly simplified to get three types og templates for selection in view: The global templates, the user's org templates and the funder templates available. - The seeds.rb file has been updated to include templates for the three groups. - The tests has been updated. --- CHANGELOG.md | 2 + app/assets/stylesheets/blocks/_forms.scss | 52 +++++- app/controllers/plans_controller.rb | 48 ++++-- app/views/plans/new.html.erb | 201 +++++++++------------- db/seeds.rb | 101 ++++++++++- spec/features/plans_spec.rb | 55 ++++-- 6 files changed, 295 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5637083df..0dbef08333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Redesigned plan-creation page to enforce template access rules and simplify UI [#3534](https://github.com/DMPRoadmap/roadmap/issues/3534) + ## v5.0.2 - Bump Ruby to v3.1.4 and use `.ruby-version` in CI - [#3566](https://github.com/DMPRoadmap/roadmap/pull/3566) diff --git a/app/assets/stylesheets/blocks/_forms.scss b/app/assets/stylesheets/blocks/_forms.scss index 59dfd49f88..1fb27cfead 100644 --- a/app/assets/stylesheets/blocks/_forms.scss +++ b/app/assets/stylesheets/blocks/_forms.scss @@ -1,21 +1,55 @@ @use '../variables/colours' as *; -.form-control { - border: 0px; -} -.form-control input, .form-control textarea, .form-control select{ - border: 2px solid $color-border-light; + +.form-control input, +.form-control textarea, +.form-control select { + border: 2px solid $color-border-light; } .form-check { - padding-left: 0rem; + padding-left: 0rem; } -.form-inline{ - margin-bottom: 5px; +.form-inline { + margin-bottom: 5px; } .form-check-label { - padding-left: 5px; + padding-left: 5px; +} + +// Added for new plan create page app/views/plans/new.html.erb +.roadmap-form { + margin-bottom: 1rem; + + legend { + padding-inline: 2px; + float: none; + width: auto; + padding: 0.5rem; + font-size: 1rem; + background-color: $color-primary-background; + color: $color-primary-text; + } + + fieldset { + border: 2px solid #555555; + padding: 1rem; + margin-bottom: 1rem; + } + + .form-row { + margin-bottom: 1rem; + } + + .form-row:last-child { + margin-bottom: 0; + } + + .form-check .form-check-input { + float: left; + margin-left: 0.5rem; + } } diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index c66bbfdeb4..44f1e7ea06 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -29,31 +29,43 @@ def index # rubocop:enable Metrics/AbcSize # GET /plans/new - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def new @plan = Plan.new authorize @plan - # Get all of the available funders and non-funder orgs - @funders = Org.funder - .includes(identifiers: :identifier_scheme) - .joins(:templates) - .where(templates: { published: true }).uniq.sort_by(&:name) - @orgs = (Org.includes(identifiers: :identifier_scheme).organisation + - Org.includes(identifiers: :identifier_scheme).institution + - Org.includes(identifiers: :identifier_scheme).default_orgs) - @orgs = @orgs.flatten.uniq.sort_by(&:name) - - @plan.org_id = current_user.org&.id + # get funder templates + funder_templates = Template.published + .joins(:org) + .merge(Org.funder) + .distinct + + # get global templates + global_templates = Template.published + .where(is_default: true) + .distinct + + # get templates of user's org + user_org_templates = Template.published + .where(org: current_user.org) + .distinct + + # create templates-grouped hash + @templates_grouped = { + _("Your Organisation's Templates:") => user_org_templates.map do |t| + [t.title, t.id] + end, + _('Global Templates:') => global_templates.map do |t| + [t.title, t.id] + end, + _('Funder Templates:') => funder_templates.map do |t| + [t.title, t.id] + end + }.reject { |_, val| val.empty? } - # TODO: is this still used? We cannot switch this to use the :plan_params - # strong params because any calls that do not include `plan` in the - # query string will fail - flash[:notice] = "#{_('This is a')} #{_('test plan')}" if params.key?(:test) - @is_test = params[:test] ||= false respond_to :html end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength # POST /plans # rubocop:disable Metrics/AbcSize, Metrics/MethodLength diff --git a/app/views/plans/new.html.erb b/app/views/plans/new.html.erb index c9838b70dd..fee232424b 100644 --- a/app/views/plans/new.html.erb +++ b/app/views/plans/new.html.erb @@ -1,125 +1,90 @@ -<% title _('Create a new plan') %> -<% required_project_title_tooltip = _('This field is required.') %> -<% project_title_tooltip = _('If applying for funding, state the project title exactly as in the proposal.') %> -<% required_research_org_tooltip = _('You must select a research organisation from the list or click the checkbox.') %> -<% research_org_tooltip = _('Please select a valid research organisation from the list.') %> -<% required_primary_funding_tooltip = _('You must select a funder from the list or click the checkbox.') %> -<% primary_funding_tooltip = _('Please select a valid funding organisation from the list.') %> - -
-
-

<%= _('Create a new plan') %>

- -

- <%= _("Before you get started, we need some information about your research project to set you up with the best DMP template for your needs.") %> -

+
+
+
+

<%= _('Create a new plan') %>

+

+ <%= _("Before you get started, we need some information about your research project to set you up with the best DMP template for your needs.") %> +

+
-
- -
-
- <%= form_for Plan.new, url: plans_path do |f| %> - -

*<%= required_project_title_tooltip %> <%= _('What research project are you planning?') %>

-
-
- <%= f.text_field(:title, class: 'form-control', 'aria-labelledby': 'project-title', 'aria-required': 'true', 'aria-label': 'project-title', - 'data-toggle': 'tooltip', - 'data-placement': 'bottom', - spellcheck: true, - title: project_title_tooltip ) %> -
-
-
- <%= label_tag(:is_test) do %> - <%= check_box_tag(:is_test, "1", false) %> - <%= _('mock project for testing, practice, or educational purposes') %> - <% end %> -
-
+
+
+
+ + <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + +
- - -

- *<%= required_research_org_tooltip %> - <%= _('Select the primary research organisation') %> -

-
-
- <%= research_org_tooltip %> - <% dflt = @orgs.include?(current_user.org) ? current_user.org : nil %> - <%= f.fields_for :org, @plan.org do |org_fields| %> - <%= render partial: "shared/org_selectors/local_only", - locals: { - form: org_fields, - id_field: :id, - default_org: dflt, - orgs: @orgs, - required: false - } %> - <% end %> -
-
- <%= _('or') %> -
-
-
- <% primary_research_org_message = _('No research organisation associated with this plan or my research organisation is not listed') %> - <%= label_tag(:plan_no_org) do %> - <%= check_box_tag(:plan_no_org, "0", false, class: "toggle-autocomplete") %> - <%= primary_research_org_message %> - <% end %> -
+
+
+
+
+ +
-
- - -

* <%= required_primary_funding_tooltip %> <%= _('Select the primary funding organisation') %>

-
-
- <%= primary_funding_tooltip %> - <%= f.fields_for :funder, @plan.funder = Org.new do |funder_fields| %> - <%= render partial: "shared/org_selectors/local_only", - locals: { - form: funder_fields, - id_field: :id, - label: _("Funder"), - default_org: nil, - orgs: @funders, - required: false - } %> - <% end %> -
-
- <%= _('or') %> -
-
-
- <% primary_funding_message = _('No funder associated with this plan or my funder is not listed') %> - <%= label_tag(:plan_no_funder) do %> - <%= check_box_tag(:plan_no_funder, "0", false, class: "toggle-autocomplete") %> - <%= primary_funding_message %> +
+
+
+
+ + <%= _('Select a DMP template') %> + +
+
+ <% @templates_grouped.each do |group_label, group_templates| %> +
+
+ <%= group_label %> +
+ <% group_templates.each do |template_title, template_id| %> +
+ + +
<% end %>
-
-
+ <% end %> - -
- <%= hidden_field_tag 'template-option-target', template_options_path %> -

<%= _('Which DMP template would you like to use?') %>

-
-
- <%= select_tag(:plan_template_id, "", name: 'plan[template_id]', - class: 'form-select', 'aria-labelledby': 'template-selection') %> -
-
- - <%= _('We found multiple DMP templates corresponding to your funder.') %> - -
-
-
- - <%= f.hidden_field(:visibility, value: @is_test ? 'is_test' : Rails.configuration.x.plans.default_visibility) %> - <%= f.button(_('Create plan'), class: "btn btn-primary", type: "submit") %> - <%= link_to _('Cancel'), plans_path, class: 'btn btn-secondary' %> - <% end %> +
+
+ +
+
+ + <%= link_to _('Cancel'), plans_path, class: 'btn btn-secondary' %> +
-
+ diff --git a/db/seeds.rb b/db/seeds.rb index 21f84bdfb2..e15070b38b 100755 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -451,8 +451,18 @@ is_default: false, locale: default_locale, version: 0, visibility: Template.visibilities[:organisationally_visible], + links: {"funder":[],"sample_plan":[]}}, + + {title: "UOS Organizational DMP Template", + published: true, + description: "Template for internal UOS organizational use", + org: Org.find_by(abbreviation: 'UOS'), + is_default: false, locale: default_locale, + version: 0, + visibility: Template.visibilities[:organisationally_visible], links: {"funder":[],"sample_plan":[]}} -] + ] + # Template creation calls defaults handler which sets is_default and # published to false automatically, so update them after creation templates.each { |atts| Template.find_or_create_by(atts) } @@ -477,13 +487,19 @@ {title: "Detailed Overview", number: 2, modifiable: false, - template: Template.find_by(title: "Department of Testing Award")} + template: Template.find_by(title: "Department of Testing Award")}, + + {title: "UOS Organizational DMP Phase 1", + number: 1, + modifiable: true, + template: Template.find_by(title: "UOS Organizational DMP Template")} ] phases.each{ |p| Phase.find_or_create_by(p) } generic_template_phase_1 = Phase.find_by(title: "Generic Data Management Planning Template") funder_template_phase_1 = Phase.find_by(title: "Preliminary Statement of Work") funder_template_phase_2 = Phase.find_by(title: "Detailed Overview") +organizational_template_phase_1 = Phase.find_by(title: "UOS Organizational DMP Phase 1") # Create sections for the 2 templates and their phases # ------------------------------------------------------- @@ -558,7 +574,28 @@ number: 5, modifiable: false, phase: funder_template_phase_2 + }, + + # Section for UOS organizational DMP Template + { + title: "Project Information", + number: 1, + modifiable: true, + phase: organizational_template_phase_1 + }, + { + title: "Project Data Description", + number: 2, + modifiable: true, + phase: organizational_template_phase_1 + }, + { + title: "Storage and Security", + number: 3, + modifiable: true, + phase: organizational_template_phase_1 } + ] sections.each{ |s| Section.find_or_create_by(s) } @@ -714,10 +751,68 @@ section: Section.find_by(title: "Preservation and Reuse Policies"), question_format: text_area, modifiable: false, - themes: [Theme.find_by(title: "Preservation"), Theme.find_by(title: "Data Sharing")]} + themes: [Theme.find_by(title: "Preservation"), Theme.find_by(title: "Data Sharing")]}, + + # Questions for UOS Organizational DMP Template + { + text: "What is the purpose of the data collection/generation and its relation to the objectives of the project?", + number: 1, + section: Section.find_by(title: "Project Information"), + question_format: QuestionFormat.find_by(title: "Text area"), + modifiable: true, + themes: [Theme.find_by(title: "Data Description")] + }, + { + text: "What types of data will you collect and in what formats?", + number: 1, + section: Section.find_by(title: "Project Data Description"), + question_format: QuestionFormat.find_by(title: "Text area"), + modifiable: true, + themes: [Theme.find_by(title: "Data Format")] + }, + { + text: "Where and how will the data be stored during the project?", + number: 1, + section: Section.find_by(title: "Storage and Security"), + question_format: QuestionFormat.find_by(title: "Text area"), + modifiable: true, + themes: [Theme.find_by(title: "Storage & Security")] + } ] questions.each{ |q| Question.create!(q) unless Question.find_by(section: q[:section], text: q[:text]) } +# Create guidance +guidance_group = GuidanceGroup.create!( + name: "Organizational Template Guidance", + org: Org.find_by(abbreviation: Rails.configuration.x.organisation.abbreviation), + optional_subset: false, + published: true +) + +guidances = [ + { + text: "Clearly describe the purpose of data collection and how it relates to project goals", + guidance_group: guidance_group, + published: true, + themes: [Theme.find_by(title: "Data Description")] + }, + { + text: "List file formats and explain why they were chosen", + guidance_group: guidance_group, + published: true, + themes: [Theme.find_by(title: "Data Format")] + }, + { + text: "Detail storage location, backup procedures and access controls", + guidance_group: guidance_group, + published: true, + themes: [Theme.find_by(title: "Storage & Security")] + } +] + +guidances.each { |g| Guidance.create!(g) } + + radio_button = Question.new( text: "Please select the appropriate formats.", number: 2, diff --git a/spec/features/plans_spec.rb b/spec/features/plans_spec.rb index eb7ba84a43..d7c1706325 100644 --- a/spec/features/plans_spec.rb +++ b/spec/features/plans_spec.rb @@ -6,13 +6,15 @@ include Webmocks before do - @default_template = create(:template, :default, :published) + # @default_template = create(:template, :default, :published) @org = create(:org) - @research_org = create(:org, :organisation, :research_institute, - name: 'Test Research Org', templates: 1) - @funding_org = create(:org, :funder, name: 'Test Funder Org', templates: 1) - @template = create(:template, org: @org) - @user = create(:user, org: @org) + @funding_org1 = create(:org, :funder, name: 'Test Funder Org1', templates: 1) + @funding_org2 = create(:org, :funder, name: 'Test Funder Org2', templates: 1) + + @global_template = create(:template, :default, :published) + @org_template = create(:template, :published, org: @org) + + @user = create(:user, org: @org) sign_in(@user) stub_openaire @@ -33,16 +35,39 @@ end it 'User creates a new Plan', :js do - # TODO: Revisit this after we start refactoring/building out or tests for - # the new create plan workflow. For some reason the plans/new.js isn't - # firing here but works fine in the UI with manual testing - # Action click_link 'Create plan' - fill_in :plan_title, with: 'My test plan' - choose_suggestion('plan_org_org_name', @research_org) - choose_suggestion('plan_funder_org_name', @funding_org) - click_button 'Create plan' + # Expect to have 4 templates available + within(:xpath, "//fieldset[./legend[contains(., 'Select a DMP template')]]") do + expect(page).to have_css('.form-check-input', count: 4) + expect(page).to have_content(@global_template.title) + expect(page).to have_content(@org_template.title) + + within(:xpath, + ".//div[contains(@class,'form-label')][contains(., 'Global Templates:')]/following-sibling::div[1]") do + expect(page).to have_css('.form-check-input', count: 1) + end + # Expect 1 template under form-label 'Your Organisation's Templates:' + # rubocop:disable Layout/LineLength + within(:xpath, + ".//div[contains(@class,'form-label')][contains(., \"Your Organisation's Templates:\")]/following-sibling::div[1]") do + expect(page).to have_css('.form-check-input', count: 1) + end + # rubocop:enable Layout/LineLength + # Expect 2 template under form-label 'Funder Templates:' + within(:xpath, + ".//div[contains(@class,'form-label')][contains(., \"Funder Templates:\")]/following-sibling::div[1]") do + expect(page).to have_css('.form-check-input', count: 1) + end + end + + fill_in 'plan[title]', with: 'My test plan' + + within(:xpath, "//fieldset[./legend[contains(., 'Select a DMP template')]]") do + find('.form-check-input', match: :first).click + end + + click_button 'Create' # Expectations expect(@user.plans).to be_one @@ -50,7 +75,5 @@ expect(current_path).to eql(plan_path(@plan)) expect(page).to have_css("input[type=text][value='#{@plan.title}']") expect(@plan.title).to eql('My test plan') - expect(@plan.org_id).to eql(@research_org.id) - expect(@plan.funder_id).to eql(@funding_org.id) end end From 4ce0dea788f66f0cd4740e3abe243a5d79cbb693 Mon Sep 17 00:00:00 2001 From: Marta Nicholson Date: Fri, 7 Nov 2025 12:57:17 +0000 Subject: [PATCH 2/5] Issue #3534 - Add the template filtering code to only display funder and/or global templates when there aren't any customised ones. --- app/controllers/plans_controller.rb | 50 ++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 44f1e7ea06..c04f7bd56c 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -29,43 +29,61 @@ def index # rubocop:enable Metrics/AbcSize # GET /plans/new - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def new @plan = Plan.new authorize @plan - - # get funder templates - funder_templates = Template.published - .joins(:org) - .merge(Org.funder) - .distinct + @plan.org_id = current_user.org&.id + # looks into the list of families of templates + customizations = Template.latest_customized_version_per_org(@plan.org_id) + customization_ids = customizations.select(&:published?).collect(&:customization_of) + + # get templates of user's own org + user_org_own_templates = Template.organisationally_visible + .where(org_id: @plan.org_id, customization_of: nil) + .published + .uniq.sort_by(&:title) + + # get templates of user's customised org + user_org_custom_templates = Template.latest_customizable.where(family_id: customization_ids) + .uniq.sort_by(&:title) + + # get funder templates no customised templates + funder_non_customised_templates = Template.published + .joins(:org) + .where(orgs: { org_type: Org.org_type_values_for(:funder) }) + # The next line removes templates that belong to a family that + # has customised templates + .where.not(family_id: customization_ids) + .uniq.sort_by(&:title) # get global templates global_templates = Template.published .where(is_default: true) - .distinct - - # get templates of user's org - user_org_templates = Template.published - .where(org: current_user.org) - .distinct + # The next line removes templates that belong to a family that + # has customised templates + .where.not(family_id: customization_ids) + .uniq.sort_by(&:title) # create templates-grouped hash @templates_grouped = { - _("Your Organisation's Templates:") => user_org_templates.map do |t| + _("Your Organisation's Templates:") => user_org_own_templates.map do |t| + [t.title, t.id] + end, + _("Your Organisation's Customised Templates:") => user_org_custom_templates.map do |t| [t.title, t.id] end, _('Global Templates:') => global_templates.map do |t| [t.title, t.id] end, - _('Funder Templates:') => funder_templates.map do |t| + _('Funder Templates:') => funder_non_customised_templates.map do |t| [t.title, t.id] end }.reject { |_, val| val.empty? } respond_to :html end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity # POST /plans # rubocop:disable Metrics/AbcSize, Metrics/MethodLength From 0046b3676bb080e067287d6406ed7064a2985245 Mon Sep 17 00:00:00 2001 From: gjacob24 Date: Fri, 7 Nov 2025 14:49:50 +0000 Subject: [PATCH 3/5] Issue #3534 - Add fix for user own org template query list. --- app/controllers/plans_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index c04f7bd56c..a20aa9767a 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -45,7 +45,8 @@ def new .uniq.sort_by(&:title) # get templates of user's customised org - user_org_custom_templates = Template.latest_customizable.where(family_id: customization_ids) + user_org_custom_templates = Template.latest_customized_version_per_org(@plan.org_id) + .published .uniq.sort_by(&:title) # get funder templates no customised templates From 3aef6d22ab878bb515ab434a33c1773959e5e28e Mon Sep 17 00:00:00 2001 From: John Pinto Date: Mon, 24 Nov 2025 11:51:45 +0000 Subject: [PATCH 4/5] Issue #3534 - Added server-side validation to ensure that an user can only create plan's from templates available to their org. --- app/controllers/plans_controller.rb | 124 +++++++++++++++++----------- 1 file changed, 74 insertions(+), 50 deletions(-) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index a20aa9767a..677addc013 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -29,62 +29,15 @@ def index # rubocop:enable Metrics/AbcSize # GET /plans/new - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def new @plan = Plan.new authorize @plan - @plan.org_id = current_user.org&.id - # looks into the list of families of templates - customizations = Template.latest_customized_version_per_org(@plan.org_id) - customization_ids = customizations.select(&:published?).collect(&:customization_of) - - # get templates of user's own org - user_org_own_templates = Template.organisationally_visible - .where(org_id: @plan.org_id, customization_of: nil) - .published - .uniq.sort_by(&:title) - - # get templates of user's customised org - user_org_custom_templates = Template.latest_customized_version_per_org(@plan.org_id) - .published - .uniq.sort_by(&:title) - - # get funder templates no customised templates - funder_non_customised_templates = Template.published - .joins(:org) - .where(orgs: { org_type: Org.org_type_values_for(:funder) }) - # The next line removes templates that belong to a family that - # has customised templates - .where.not(family_id: customization_ids) - .uniq.sort_by(&:title) - - # get global templates - global_templates = Template.published - .where(is_default: true) - # The next line removes templates that belong to a family that - # has customised templates - .where.not(family_id: customization_ids) - .uniq.sort_by(&:title) - - # create templates-grouped hash - @templates_grouped = { - _("Your Organisation's Templates:") => user_org_own_templates.map do |t| - [t.title, t.id] - end, - _("Your Organisation's Customised Templates:") => user_org_custom_templates.map do |t| - [t.title, t.id] - end, - _('Global Templates:') => global_templates.map do |t| - [t.title, t.id] - end, - _('Funder Templates:') => funder_non_customised_templates.map do |t| - [t.title, t.id] - end - }.reject { |_, val| val.empty? } + org_id = current_user.org&.id + # Get templates grouped hash + @templates_grouped = templates_available_to_org_user(org_id) respond_to :html end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity # POST /plans # rubocop:disable Metrics/AbcSize, Metrics/MethodLength @@ -102,6 +55,15 @@ def create format.html { redirect_to new_plan_path } end else + template_id = plan_params[:template_id].to_i + unless validate_template_available_to_org_user?(template_id, current_user.org_id) + respond_to do |format| + flash[:alert] = _('The selected template is not available to your organisation.') + format.html { redirect_to new_plan_path } + end + return + end + @plan.visibility = if plan_params['visibility'].blank? Rails.configuration.x.plans.default_visibility else @@ -568,5 +530,67 @@ def render_phases_edit(plan, phase, guidance_groups) guidance_presenter: GuidancePresenter.new(plan) }) end + + # Get templates available to org users + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def templates_available_to_org_user(org_id) + # looks into the list of families of templates + customizations = Template.latest_customized_version_per_org(org_id) + customization_ids = customizations.select(&:published?).collect(&:customization_of) + + # get templates of user's own org + user_org_own_templates = Template.organisationally_visible + .where(org_id: org_id, customization_of: nil) + .published + .uniq.sort_by(&:title) + + # get templates of user's customised org + user_org_custom_templates = Template.latest_customized_version_per_org(org_id) + .published + .uniq.sort_by(&:title) + + # get funder templates no customised templates + funder_non_customised_templates = Template.published + .joins(:org) + .where(orgs: { org_type: Org.org_type_values_for(:funder) }) + # The next line removes templates that belong to a family that + # has customised templates + .where.not(family_id: customization_ids) + .uniq.sort_by(&:title) + + # get global templates + global_templates = Template.published + .where(is_default: true) + # The next line removes templates that belong to a family that + # has customised templates + .where.not(family_id: customization_ids) + .uniq.sort_by(&:title) + + # create templates-grouped hash + @templates_grouped = { + _("Your Organisation's Templates:") => user_org_own_templates.map do |t| + [t.title, t.id] + end, + _("Your Organisation's Customised Templates:") => user_org_custom_templates.map do |t| + [t.title, t.id] + end, + _('Global Templates:') => global_templates.map do |t| + [t.title, t.id] + end, + _('Funder Templates:') => funder_non_customised_templates.map do |t| + [t.title, t.id] + end + }.reject { |_, val| val.empty? } + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + # Validate that a template_id is available to the org user + def validate_template_available_to_org_user?(template_id, org_id) + return false if template_id.blank? || org_id.blank? + + available_templates = templates_available_to_org_user(org_id) + available_template_ids = available_templates.values.flat_map { |group| group.map(&:last) } + available_template_ids.include?(template_id.to_i) + end end # rubocop:enable Metrics/ClassLength From 191c8f68508b8766089956b1ca918a2f8952e742 Mon Sep 17 00:00:00 2001 From: John Pinto Date: Tue, 25 Nov 2025 10:30:06 +0000 Subject: [PATCH 5/5] Issue #3534 - Added a new RSpec test file plans_controller_templates_spec.rb. This deals with testing methods used by the new Plan creation functionality introduced. --- .../plans_controller_templates_spec.rb | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 spec/controllers/plans_controller_templates_spec.rb diff --git a/spec/controllers/plans_controller_templates_spec.rb b/spec/controllers/plans_controller_templates_spec.rb new file mode 100644 index 0000000000..b5b6ce91fe --- /dev/null +++ b/spec/controllers/plans_controller_templates_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PlansController, type: :controller do + describe 'templates availability helpers' do + before do + @org = create(:org, :organisation, name: 'The User Org') + @org_id = @org.id + + @org_template = create(:template, :default, :organisationally_visible, :published, org: @org, + title: 'Org Template') + + @funder1 = create(:org, :funder, name: 'Funder1 Org') + @funder1_template = create(:template, :publicly_visible, :published, + org: @funder1, title: 'Funder1 Template') + + @org_custom_funder1_template = create(:template, :organisationally_visible, :published, + org: @org, customization_of: @funder1_template.family_id, + title: 'Org Customised Funder1 Template') + + @funder2 = create(:org, :funder, name: 'Funder2 Org') + @funder2_template = create(:template, :publicly_visible, :published, org: @funder2, title: 'Funder2 Template') + + @global_template = create(:template, :default, :publicly_visible, :published, title: 'Global Template') + + @other_org = create(:org, :organisation, name: 'The Other Org') + @other_org_template = create(:template, :organisationally_visible, :published, + org: @other_org, title: 'Other Org Template') + end + + it 'returns a grouped hash containing the available templates' do + grouped = controller.send(:templates_available_to_org_user, @org_id) + expect(grouped).to be_a(Hash) + puts "Grouped templates available to org id #{@org_id}: #{grouped.keys.inspect}" + + # flatten one level to inspect [title, id] entries across groups + entries = grouped.values.flatten(1) + puts entries.inspect + # Available templates + expect(entries).to include([@org_template.title, @org_template.id]) + expect(entries).to include([@org_custom_funder1_template.title, @org_custom_funder1_template.id]) + expect(entries).to include([@funder2_template.title, @funder2_template.id]) + expect(entries).to include([@global_template.title, @global_template.id]) + # Not available templates + # As the org has already customised funder1_template, it should not appear in the available list + expect(entries).not_to include([@funder1_template.title, @funder1_template.id]) + # Templates from other orgs should not appear if only organisationally visible + expect(entries).not_to include([@other_org_template.title, @other_org_template.id]) + end + + it 'validates a template id that is available for the org' do + expect(controller.send(:validate_template_available_to_org_user?, @org_template.id, @org_id)).to be true + expect(controller.send(:validate_template_available_to_org_user?, @org_custom_funder1_template.id, + @org_id)).to be true + expect(controller.send(:validate_template_available_to_org_user?, @funder2_template.id, @org_id)).to be true + expect(controller.send(:validate_template_available_to_org_user?, @global_template.id, @org_id)).to be true + end + + it 'returns false for a template id that is not available for the org' do + # As the org has already customised funder1_template, it should not be available + expect(controller.send(:validate_template_available_to_org_user?, @funder1_template.id, @org_id)).to be false + # Templates from other orgs should not be available if only organisationally visible + expect(controller.send(:validate_template_available_to_org_user?, @other_org_template.id, @org_id)).to be false + end + end +end