diff --git a/app/controllers/projects/settings/subitems_controller.rb b/app/controllers/projects/settings/subitems_controller.rb new file mode 100644 index 00000000000..0381c54c02d --- /dev/null +++ b/app/controllers/projects/settings/subitems_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Projects::Settings::SubitemsController < Projects::SettingsController + menu_item :settings_subitems + + def show; end + + def update + workspace_type = params[:workspace_type] + template_id = params[:template_id] + + assignment = @project.subproject_template_assignments.find_or_initialize_by(workspace_type:) + + if template_id.blank? + if assignment.persisted? + assignment.destroy + flash[:notice] = I18n.t(:notice_successful_update) + end + else + assignment.template_id = template_id + if assignment.save + flash[:notice] = I18n.t(:notice_successful_update) + else + flash[:error] = assignment.errors.full_messages.join(", ") + end + end + + redirect_to project_settings_subitems_path(@project) + end + +end diff --git a/app/forms/projects/settings/subitems_template_form.rb b/app/forms/projects/settings/subitems_template_form.rb new file mode 100644 index 00000000000..6f8b51b83c6 --- /dev/null +++ b/app/forms/projects/settings/subitems_template_form.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Projects + module Settings + class SubitemsTemplateForm < ApplicationForm + attr_reader :project, :assignments + + def initialize(project:) + super() + + @project = project + @assignments = @project.subproject_template_assignments + end + + form do |f| + if project.portfolio? + program_template = assignments.detect(&:program?) + + f.select_list( + name: :program_template, + scope_name_to_model: false, + label: I18n.t("projects.settings.subitems.program_template_label"), + caption: I18n.t("projects.settings.subitems.program_template_caption"), + input_width: :large, + include_blank: I18n.t("projects.settings.subitems.no_template") + ) do |list| + available_templates + .workspace_type(:program) + .find_each do |template| + list.option( + value: template.id, + label: template.name, + selected: template.id == program_template&.template_id + ) + end + end + end + + project_template = assignments.detect(&:project?) + f.select_list( + name: :project_template, + scope_name_to_model: false, + label: I18n.t("projects.settings.subitems.project_template_label"), + caption: I18n.t("projects.settings.subitems.project_template_caption"), + input_width: :large, + include_blank: I18n.t("projects.settings.subitems.no_template") + ) do |list| + available_templates + .workspace_type(:project) + .find_each do |template| + list.option( + value: template.id, + label: template.name, + selected: template.id == project_template&.template_id + ) + end + end + + f.submit( + name: :submit, + label: I18n.t(:button_save), + scheme: :primary + ) + end + + private + + def available_templates + Project + .visible(User.current) + .active + .templated + .order(name: :asc) + end + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index f78b39fb0e0..e46333e4660 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -111,12 +111,19 @@ class Project < ApplicationRecord has_many :recurring_meetings, dependent: :destroy belongs_to :template, class_name: "Project", optional: true + has_many :templated_projects, class_name: "Project", foreign_key: "template_id", inverse_of: :template, dependent: nil + has_many :subproject_template_assignments, + class_name: "SubprojectTemplateAssignment", + foreign_key: "template_id", + inverse_of: :template, + dependent: :delete_all + accepts_nested_attributes_for :available_phases validates_associated :available_phases, on: :saving_phases diff --git a/app/models/subproject_template_assignment.rb b/app/models/subproject_template_assignment.rb new file mode 100644 index 00000000000..211fee25141 --- /dev/null +++ b/app/models/subproject_template_assignment.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class SubprojectTemplateAssignment < ApplicationRecord + belongs_to :project, inverse_of: :subproject_template_assignments + belongs_to :template, class_name: "Project", inverse_of: :template_assignments + + enum :workspace_type, { + project: "project", + program: "program" + }, validate: true + + validates :project_id, presence: true + validates :template_id, presence: true + validates :workspace_type, presence: true + validates :project_id, uniqueness: { scope: :workspace_type } + + validate :template_must_be_templated + + private + + def template_must_be_templated + return if template.blank? + + unless template.templated? + errors.add(:template, :must_be_template) + end + end +end diff --git a/app/views/projects/settings/subitems/show.html.erb b/app/views/projects/settings/subitems/show.html.erb new file mode 100644 index 00000000000..7d48337d713 --- /dev/null +++ b/app/views/projects/settings/subitems/show.html.erb @@ -0,0 +1,65 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { I18n.t(:label_subitems) } + header.with_breadcrumbs( + [{ href: project_overview_path(@project.id), text: @project.name }, + { href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") }, + I18n.t(:label_subitems)] + ) + + header.with_tab_nav(label: nil) do |tab_nav| + tab_nav.with_tab( + selected: true, + href: project_settings_subitems_path(@project) + ) do |t| + t.with_text { I18n.t(:label_templates) } + end + end + end +%> + +<%= error_messages_for(@project) %> + +<%= render Primer::Beta::Text.new { I18n.t("projects.settings.subitems.template_section") } %> + +<%= + settings_primer_form_with(url: project_settings_subitems_path(@project)) do |f| + render( + Primer::Forms::FormList.new( + Projects::Settings::SubitemsTemplateForm.new( + f, + project: @project, + ) + ) + ) + end +%> diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index a227a883cd5..f11c653427a 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -731,6 +731,7 @@ Redmine::MenuManager.map :project_menu do |menu| if: ->(_) { OpenProject::FeatureDecisions.project_initiation_active? } }, modules: { caption: :label_module_plural }, + subitems: { caption: :label_subitems }, work_packages: { caption: :label_work_package_plural, if: ->(project) { diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 7ac8a0bc91f..53ab2d00ef6 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -153,6 +153,7 @@ Rails.application.reloader.to_prepare do update_artifact_export_settings toggle_project_custom_field disable_all_of_section enable_all_of_section], + "projects/settings/subitems": %i[show update], "projects/templated": %i[create destroy], "projects/identifier": %i[show update], "projects/status": %i[update destroy] diff --git a/config/locales/en.yml b/config/locales/en.yml index 34ae4b80034..de1c43728e4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -559,6 +559,14 @@ en: The project will only be visible to project members depending on their role and associated permissions. Sub-projects are not affected and have their own settings. change_identifier: Change identifier + subitems: + template_section: > + Select templates to be used when creating new subitems. + project_template_label: "Template for projects" + project_template_caption: "Select a template project to be used as the default for new subitems of this type." + program_template_label: "Template for programs" + program_template_caption: "Select a template program to be used as the default for new subitems of this type." + no_template: "No predefined template" actions: label_enable_all: "Enable all" label_disable_all: "Disable all" @@ -3610,6 +3618,7 @@ en: label_subproject: "Subproject" label_subproject_new: "New subproject" label_subproject_plural: "Subprojects" + label_subitems: "Subitems" label_subtask_plural: "Subtasks" label_summary: "Summary" label_system: "System" @@ -3617,6 +3626,8 @@ en: label_table_of_contents: "Table of contents" label_tag: "Tag" label_team_planner: "Team Planner" + label_template: "Template" + label_templates: "Templates" label_text: "Long text" label_this_month: "this month" label_this_week: "this week" diff --git a/config/routes.rb b/config/routes.rb index 6fa090d8f66..ed83d327a04 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -285,6 +285,7 @@ Rails.application.routes.draw do post :toggle_public end resource :modules, only: %i[show update] + resource :subitems, only: %i[show update] resource :creation_wizard, controller: "creation_wizard", only: %i[show] do get :disable_dialog post :toggle diff --git a/db/migrate/20251125000000_create_subproject_template_assignments.rb b/db/migrate/20251125000000_create_subproject_template_assignments.rb new file mode 100644 index 00000000000..2671f6171a5 --- /dev/null +++ b/db/migrate/20251125000000_create_subproject_template_assignments.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class CreateSubprojectTemplateAssignments < ActiveRecord::Migration[8.0] + def change + create_table :subproject_template_assignments do |t| + t.references :project, null: false, foreign_key: { on_delete: :cascade }, index: true + t.references :template, null: false, foreign_key: { to_table: :projects, on_delete: :cascade }, index: true + t.string :workspace_type, null: false, index: true + + t.timestamps + + t.index %i[project_id workspace_type], unique: true, name: "idx_subproj_tmpl_assignments_on_project_workspace" + end + end +end diff --git a/package-lock.json b/package-lock.json index 1feb52a0644..cbca62e71ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -424,6 +424,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", diff --git a/spec/models/subproject_template_assignment_spec.rb b/spec/models/subproject_template_assignment_spec.rb new file mode 100644 index 00000000000..e07bbdd3f6b --- /dev/null +++ b/spec/models/subproject_template_assignment_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe SubprojectTemplateAssignment do + let(:project) { create(:project) } + let(:template) { create(:project, templated: true) } + + describe "associations" do + it { is_expected.to belong_to(:project).inverse_of(:subproject_template_assignments) } + it { is_expected.to belong_to(:template).class_name("Project").inverse_of(:template_assignments) } + + it "allows accessing assignments from project" do + assignment = create(:subproject_template_assignment, project:, template:) + expect(project.subproject_template_assignments).to include(assignment) + end + end + + describe "enums" do + it { + expect(described_class).to define_enum_for(:workspace_type) + .with_values(project: "project", program: "program") + .backed_by_column_of_type(:string) + } + end + + describe "validations" do + subject(:assignment) do + build(:subproject_template_assignment, + project:, + template:, + workspace_type: "project") + end + + it { is_expected.to validate_presence_of(:project_id) } + it { is_expected.to validate_presence_of(:template_id) } + it { is_expected.to validate_presence_of(:workspace_type) } + + describe "uniqueness" do + before do + create(:subproject_template_assignment, + project:, + template:, + workspace_type: "project") + end + + it "allows only one assignment per project and workspace_type" do + duplicate = build(:subproject_template_assignment, + project:, + template:, + workspace_type: "project") + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:project_id]).to include("has already been taken") + end + + it "allows different workspace_types for the same project" do + different_type = build(:subproject_template_assignment, + project:, + template:, + workspace_type: "program") + + expect(different_type).to be_valid + end + + it "allows same workspace_type for different projects" do + other_project = create(:project) + different_project = build(:subproject_template_assignment, + project: other_project, + template:, + workspace_type: "project") + + expect(different_project).to be_valid + end + end + + describe "template validation" do + context "when template is marked as templated" do + let(:template) { create(:project, templated: true) } + + it "is valid" do + expect(assignment).to be_valid + end + end + + context "when template is not marked as templated" do + let(:template) { create(:project, templated: false) } + + it "is invalid" do + expect(assignment).not_to be_valid + expect(assignment.errors[:template]).to include("must be template") + end + end + end + end + + describe "cascading deletes" do + let!(:assignment) do + create(:subproject_template_assignment, + project:, + template:) + end + + context "when project is deleted" do + it "deletes the assignment via cascade" do + expect { project.destroy }.to change(described_class, :count).by(-1) + end + end + + context "when template is deleted" do + it "deletes the assignment via cascade" do + expect { template.destroy }.to change(described_class, :count).by(-1) + end + end + end + + describe "factory" do + it "creates a valid assignment with default attributes" do + assignment = build(:subproject_template_assignment) + expect(assignment).to be_valid + end + + it "creates a valid assignment with :for_project trait" do + assignment = build(:subproject_template_assignment, :for_project) + expect(assignment).to be_valid + expect(assignment.workspace_type).to eq("project") + expect(assignment.template.workspace_type).to eq("project") + end + + it "creates a valid assignment with :for_program trait" do + assignment = build(:subproject_template_assignment, :for_program) + expect(assignment).to be_valid + expect(assignment.workspace_type).to eq("program") + expect(assignment.template.workspace_type).to eq("program") + end + end +end